From e90b8716c2ef2ed545f3dc53651253d54f0b604d Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 18 Aug 2026 20:34:01 +0200 Subject: [PATCH 1/3] feat(prepare-sources): implement generate-manifests pipeline module Generate manifest.json (workspace package inventory) and, when the workspace contains backstage:^ dependencies, backstage-manifest.json with @backstage package metadata extracted from yarn.lock and validated against versions.backstage.io. Add a zero-dependency Yarn Berry lockfile parser/serializer for round-trip fidelity, shared manifest types, and fixture-based tests. Extend test utilities with a mockFetch helper and wire the module into the pipeline runner. Add @vitest/coverage-v8 and ignore the coverage directory. Assisted-by: Cursor Signed-off-by: David Festal --- .sonarcloud.properties | 2 + prepare-sources/lib/manifest-types.ts | 39 + prepare-sources/lib/modules.ts | 3 +- .../lib/modules/generate-manifests/README.md | 204 ++++ .../backstage-json-no-version-error/error | 1 + .../input/overlay/source.json | 6 + .../input/workspace/backstage.json | 3 + .../input/workspace/package.json | 8 + .../workspace/plugins/my-plugin/package.json | 7 + .../input/overlay/source.json | 6 + .../input/workspace/package.json | 8 + .../output/workspace/manifest.json | 15 + .../output/workspace/package.json | 8 + .../missing-yarn-lock-error/error | 1 + .../input/overlay/source.json | 6 + .../input/workspace/backstage.json | 3 + .../input/workspace/package.json | 8 + .../workspace/plugins/my-plugin/package.json | 7 + .../input/overlay/source.json | 6 + .../input/workspace/package.json | 8 + .../plugins/plugin-alpha/package.json | 26 + .../plugins/plugin-beta/package.json | 8 + .../output/workspace/manifest.json | 47 + .../output/workspace/package.json | 8 + .../plugins/plugin-alpha/package.json | 26 + .../plugins/plugin-beta/package.json | 8 + .../input/overlay/source.json | 6 + .../input/workspace/backstage.json | 3 + .../input/workspace/package.json | 8 + .../workspace/plugins/my-plugin/package.json | 8 + .../input/workspace/yarn.lock | 33 + .../output/workspace/backstage-manifest.json | 34 + .../output/workspace/backstage.json | 3 + .../output/workspace/manifest.json | 18 + .../output/workspace/package.json | 8 + .../workspace/plugins/my-plugin/package.json | 8 + .../output/workspace/yarn.lock | 33 + .../remote-backstage-manifest.json | 8 + .../no-backstage-json-error/error | 1 + .../input/overlay/source.json | 6 + .../input/workspace/package.json | 8 + .../workspace/plugins/my-plugin/package.json | 7 + .../__fixtures__/version-mismatch-error/error | 1 + .../input/overlay/source.json | 6 + .../input/workspace/backstage.json | 3 + .../input/workspace/package.json | 8 + .../workspace/plugins/my-plugin/package.json | 7 + .../input/workspace/yarn.lock | 8 + .../remote-backstage-manifest.json | 3 + .../modules/generate-manifests/index.test.ts | 364 +++++++ .../lib/modules/generate-manifests/index.ts | 365 +++++++ prepare-sources/lib/test-utils.ts | 71 +- prepare-sources/lib/yarn-lock-parser.test.ts | 953 ++++++++++++++++++ prepare-sources/lib/yarn-lock-parser.ts | 392 +++++++ 54 files changed, 2843 insertions(+), 2 deletions(-) create mode 100644 prepare-sources/lib/manifest-types.ts create mode 100644 prepare-sources/lib/modules/generate-manifests/README.md create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/error create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/backstage.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/plugins/my-plugin/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/output/workspace/manifest.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/output/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/error create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/backstage.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/plugins/my-plugin/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/plugins/plugin-alpha/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/plugins/plugin-beta/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/manifest.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/plugins/plugin-alpha/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/plugins/plugin-beta/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/backstage.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/plugins/my-plugin/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/yarn.lock create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/backstage-manifest.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/backstage.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/manifest.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/plugins/my-plugin/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/yarn.lock create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/remote-backstage-manifest.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/error create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/workspace/plugins/my-plugin/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/error create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/backstage.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/plugins/my-plugin/package.json create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/yarn.lock create mode 100644 prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/remote-backstage-manifest.json create mode 100644 prepare-sources/lib/modules/generate-manifests/index.test.ts create mode 100644 prepare-sources/lib/modules/generate-manifests/index.ts create mode 100644 prepare-sources/lib/yarn-lock-parser.test.ts create mode 100644 prepare-sources/lib/yarn-lock-parser.ts diff --git a/.sonarcloud.properties b/.sonarcloud.properties index b832489..604b9ef 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -1,2 +1,4 @@ # Test fixtures are not production code; skip analysis. sonar.exclusions=prepare-sources/**/__fixtures__/**/* +# Code duplication detection usually doesn't make sense in test files. +sonar.cpd.exclusions=prepare-sources/lib/**/*.test.ts diff --git a/prepare-sources/lib/manifest-types.ts b/prepare-sources/lib/manifest-types.ts new file mode 100644 index 0000000..809288c --- /dev/null +++ b/prepare-sources/lib/manifest-types.ts @@ -0,0 +1,39 @@ +/** + * Shape of `manifest.json` — inventory of all workspace packages. + * Written by `generate-manifests`, consumed by `protocol-resolution`. + */ +export type WorkspaceManifest = { + packages: WorkspacePackageEntry[]; +}; + +export type WorkspacePackageEntry = { + name: string; + version: string; + path?: string; + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record>; + optionalDependencies?: Record; + devDependencies?: Record; + bin?: Record; +}; + +/** + * Shape of `backstage-manifest.json` — Backstage release packages with + * dependency metadata. Written by `generate-manifests` (only when the + * workspace has `backstage:^` deps), consumed by `protocol-resolution`. + */ +export type BackstageManifest = { + backstageVersion: string; + packages: BackstagePackageEntry[]; +}; + +export type BackstagePackageEntry = { + name: string; + version: string; + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record>; + optionalDependencies?: Record; + bin?: Record; +}; diff --git a/prepare-sources/lib/modules.ts b/prepare-sources/lib/modules.ts index 445011d..d3e627a 100644 --- a/prepare-sources/lib/modules.ts +++ b/prepare-sources/lib/modules.ts @@ -1,6 +1,7 @@ import type { ModuleContext, PipelineModule } from "./pipeline.ts"; import { run as hermeticPrep } from "./modules/hermetic-prep/index.ts"; import { run as makeSelfContained } from "./modules/make-self-contained/index.ts"; +import { run as generateManifests } from "./modules/generate-manifests/index.ts"; async function notImplemented(ctx: ModuleContext): Promise { ctx.log("not yet implemented"); @@ -10,7 +11,7 @@ async function notImplemented(ctx: ModuleContext): Promise { export const MODULES: readonly PipelineModule[] = [ { name: "seed-frontend-lockfiles", run: notImplemented }, { name: "make-self-contained", run: makeSelfContained }, - { name: "generate-manifests", run: notImplemented }, + { name: "generate-manifests", run: generateManifests }, { name: "plugin-removal", run: notImplemented }, { name: "file-cleanup", run: notImplemented }, { name: "protocol-resolution", run: notImplemented }, diff --git a/prepare-sources/lib/modules/generate-manifests/README.md b/prepare-sources/lib/modules/generate-manifests/README.md new file mode 100644 index 0000000..6f666be --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/README.md @@ -0,0 +1,204 @@ +# generate-manifests + +Generates package inventories consumed by `protocol-resolution` to resolve +`workspace:^` and `backstage:^` references to concrete npm semver ranges. + +**Pipeline position:** after `make-self-contained`, before `plugin-removal` — +see `modules.ts` for the current ordering. + +**Shared types:** `WorkspaceManifest`, `BackstageManifest`, and their entry +types live in `lib/manifest-types.ts`. Both this module and +`protocol-resolution` depend on those types. + +## Problem + +The `protocol-resolution` module needs to rewrite `workspace:^` and +`backstage:^` protocol references in `package.json` and `yarn.lock`. To do +this, it needs a lookup table mapping package names to their concrete versions +and dependency metadata. + +This information must be captured **before scrubbing** (`plugin-removal`), +because scrubbed packages are removed from disk but their versions are still +needed for protocol resolution — surviving packages may depend on scrubbed +ones via `workspace:^`. + +## What the module produces + +### `manifest.json` + +Inventory of all local workspace packages. Always generated. + +```json +{ + "packages": [ + { + "name": "@scope/plugin-foo", + "version": "1.2.3", + "path": "plugins/plugin-foo/package.json", + "dependencies": { "@scope/plugin-bar": "workspace:^" }, + "peerDependencies": { "react": "^18.0.0" }, + "peerDependenciesMeta": { "react": { "optional": true } }, + "optionalDependencies": { "@emotion/react": "^11.0.0" }, + "devDependencies": { "@types/react": "^18.0.0" }, + "bin": { "foo": "./dist/cli.js" } + } + ] +} +``` + +All dependency fields are optional — only included when non-empty. + +**Package discovery:** Packages are discovered via the `workspaces` field in +the root `package.json` (glob resolution), plus the root package itself. For +flat repos (no `workspaces` field), only the root package is included. + +**Filtering:** Glob matches whose path segments include any of `node_modules`, +`dist-dynamic`, `dist-scalprum`, `dist`, or `build` are excluded. This +prevents build output directories from polluting the manifest. + +**`backstage:^` detection:** The module checks `dependencies`, +`devDependencies`, `peerDependencies`, and `optionalDependencies` across all +packages for `backstage:^` values. If none are found, `backstage-manifest.json` +is skipped entirely. + +### `backstage-manifest.json` + +Backstage release packages with dependency metadata. Only generated when the +workspace has `backstage:^` dependencies. Requires `backstage.json` at the +workspace root (throws if missing). + +```json +{ + "backstageVersion": "1.42.5", + "packages": [ + { + "name": "@backstage/core-plugin-api", + "version": "1.10.9", + "dependencies": { "@backstage/types": "npm:^1.2.3" }, + "peerDependencies": { "react": "npm:^18.0.0" }, + "peerDependenciesMeta": { "react": { "optional": true } }, + "optionalDependencies": { "@emotion/react": "npm:^11.0.0" }, + "bin": { "backstage-core": "./dist/cli.js" } + } + ] +} +``` + +Note that dependency values in the backstage manifest use the Yarn-resolved +format (`npm:^X.Y.Z`), since they come from `yarn.lock` rather than +`package.json`. + +Neither file is included in the OCI artifact — they are build-time +intermediates. + +## Why backstage metadata comes from yarn.lock, not the npm registry + +The original `generate-backstage-manifest.js` in `sync-midstream.sh` fetched +the Backstage release manifest from `versions.backstage.io` (one HTTP call), +then enriched each of the ~600 packages with dependency metadata from +`registry.npmjs.org` (60+ batched HTTP calls). The result was cached to disk +by Backstage version to amortize the cost across workspaces. + +This module takes a different approach: it **extracts backstage package +metadata directly from the workspace's `yarn.lock`**, using the shared +`yarn-lock-parser` (`lib/yarn-lock-parser.ts`). + +### Rationale + +After `yarn install --immutable` (which runs before this module), the +`yarn.lock` already contains the resolved version and full dependency metadata +for every `@backstage/*` package. These entries were created by the Backstage +Yarn plugin, which resolves `backstage:^` by looking up the exact version from +`versions.backstage.io` at install time. So the lockfile is a local cache of +the canonical version mapping, enriched with the dependency metadata that npm +registry calls would have provided. + +**Benefits:** + +- **Zero npm registry calls.** The original script made hundreds of HTTP + requests. This module reads local files only (plus one validation fetch). +- **No caching needed.** The original script cached results to `/tmp/` to + avoid redundant fetches across workspaces in the same `sync-midstream.sh` + run. In the new per-workspace CI model (separate workflow run per + workspace), there's no shared filesystem to cache to. Since we read from the + lockfile, there's nothing to cache. +- **More accurate.** The lockfile reflects what was actually resolved for this + specific workspace, not a generic npm registry response. + +**Validation:** The module fetches the Backstage release manifest from +`versions.backstage.io` (a single HTTP call) and compares each extracted +version against the canonical manifest. A mismatch throws — indicating a stale +lockfile or unexpected inconsistency. This is a defensive check; by +construction, `yarn install --immutable` guarantees the lockfile is consistent +with the Backstage Yarn plugin's resolution. + +### Why the versions are guaranteed to match + +The `backstage:^` protocol is resolved by the Backstage Yarn plugin, which: + +1. Reads `backstage.json` to get the target release version +2. Fetches `versions.backstage.io/v1/releases//manifest.json` +3. Resolves each `backstage:^` to the exact version from that manifest +4. Writes the result to `yarn.lock` + +After `yarn install --immutable`, the lockfile entries for `backstage:^` are +by construction the same versions as the canonical manifest. The validation +fetch is belt-and-suspenders — it catches corruption, plugin bugs, or manual +lockfile edits that should never happen in CI. + +## Why upstream fetching is no longer needed + +The original `generate-workspace-manifest.js` fetched versions from GitHub for +packages that didn't exist locally — these were packages referenced by +`workspace:^` but deleted by scrubbing before the manifest was generated. The +manifest tagged these entries with `path: 'upstream'` and `source: 'upstream'` +so that downstream consumers (`createTypeShimsPackage` in `update-workspace.js`) +could distinguish them from packages that were once local. + +In the new pipeline, this entire mechanism is unnecessary because manifest +generation runs **before** `plugin-removal`. All workspace packages are still +on disk, so every entry in the manifest has a real `path`. When +`protocol-resolution` later needs to distinguish surviving from scrubbed +packages, it simply checks `fs.existsSync(dirname(pkg.path))` — exactly as the +old `update-workspace.js` already does (lines 1722, 1736, 1799). The +`path === 'upstream'` marker is never needed. + +## Differences from sync-midstream.sh + +| Aspect | Original (sync-midstream.sh) | New (this module) | +| ------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | +| Upstream version fetching | Fetches missing `workspace:` deps from GitHub raw URLs | Not needed — all packages are local (runs before scrubbing) | +| Backstage metadata source | `versions.backstage.io` + npm registry (~600 HTTP calls) | `yarn.lock` extraction + one validation fetch | +| Caching | `/tmp/backstage-manifest-cache/` (shared across workspaces) | None needed | +| Error handling | Swallowed (`\|\| true`, stderr discarded) | Throws on error (pipeline aborts) | +| Package discovery | Recursive scan with skip-list heuristics | `workspaces` field glob resolution + filter | +| `bin` normalization | String → object (same) | String → object (same) | +| yarn.lock parsing | N/A (backstage manifest came from npm) | Shared `yarn-lock-parser` (`lib/yarn-lock-parser.ts`) | + +## Log messages + +Log messages are aligned with the original scripts for familiarity: + +| Log | When | +| ------------------------------------------------- | --------------------------------- | +| `Scanning for package.json files...` | Always (start of module) | +| `Found N local packages` | Always | +| ` - @scope/pkg@1.2.3` | Per package | +| `Manifest written to: manifest.json` | Always | +| `No backstage:^ dependencies found, skipping` | No `backstage:^` in any dep field | +| `Backstage version: X.Y.Z` | Backstage manifest path | +| `Extracted N @backstage/* entries from yarn.lock` | After lockfile parsing | +| `Validating against ` | Before validation fetch | +| `All N entries validated against Backstage X.Y.Z` | After successful validation | +| `Written: backstage-manifest.json (N packages)` | Backstage manifest written | + +## Error conditions + +| Condition | Behavior | +| ---------------------------------------------------------------- | ------------------------------------------- | +| No `backstage:^` deps in any `package.json` | Skip — no `backstage-manifest.json` written | +| Has `backstage:^` deps but no `backstage.json` | Throw | +| `backstage.json` exists but has no `version` field | Throw | +| `yarn.lock` missing (when backstage manifest needed) | Throw | +| Version mismatch between `yarn.lock` and `versions.backstage.io` | Throw with details | +| Failed to fetch `versions.backstage.io` | Throw with HTTP status | diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/error b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/error new file mode 100644 index 0000000..97f6ca6 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/error @@ -0,0 +1 @@ +/no 'version' field/ \ No newline at end of file diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/overlay/source.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/backstage.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/backstage.json new file mode 100644 index 0000000..4df5066 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/backstage.json @@ -0,0 +1,3 @@ +{ + "description": "backstage.json without a version field" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/package.json new file mode 100644 index 0000000..0136fd8 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*" + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/plugins/my-plugin/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/plugins/my-plugin/package.json new file mode 100644 index 0000000..c47611b --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/backstage-json-no-version-error/input/workspace/plugins/my-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/my-plugin", + "version": "1.0.0", + "dependencies": { + "@backstage/core-plugin-api": "backstage:^" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/input/overlay/source.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/input/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/input/workspace/package.json new file mode 100644 index 0000000..6da0c9e --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/input/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/standalone-plugin", + "version": "3.0.1", + "bin": "./dist/cli.js", + "dependencies": { + "express": "^4.18.0" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/output/workspace/manifest.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/output/workspace/manifest.json new file mode 100644 index 0000000..e54c2d3 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/output/workspace/manifest.json @@ -0,0 +1,15 @@ +{ + "packages": [ + { + "name": "@test/standalone-plugin", + "version": "3.0.1", + "path": "package.json", + "dependencies": { + "express": "^4.18.0" + }, + "bin": { + "standalone-plugin": "./dist/cli.js" + } + } + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/output/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/output/workspace/package.json new file mode 100644 index 0000000..6da0c9e --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/flat-repo-single-package/output/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/standalone-plugin", + "version": "3.0.1", + "bin": "./dist/cli.js", + "dependencies": { + "express": "^4.18.0" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/error b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/error new file mode 100644 index 0000000..1eab240 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/error @@ -0,0 +1 @@ +/yarn\.lock not found/ \ No newline at end of file diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/overlay/source.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/backstage.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/backstage.json new file mode 100644 index 0000000..6c12971 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/backstage.json @@ -0,0 +1,3 @@ +{ + "version": "1.42.5" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/package.json new file mode 100644 index 0000000..0136fd8 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*" + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/plugins/my-plugin/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/plugins/my-plugin/package.json new file mode 100644 index 0000000..c47611b --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/missing-yarn-lock-error/input/workspace/plugins/my-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/my-plugin", + "version": "1.0.0", + "dependencies": { + "@backstage/core-plugin-api": "backstage:^" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/overlay/source.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/package.json new file mode 100644 index 0000000..0136fd8 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*" + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/plugins/plugin-alpha/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/plugins/plugin-alpha/package.json new file mode 100644 index 0000000..fb49c94 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/plugins/plugin-alpha/package.json @@ -0,0 +1,26 @@ +{ + "name": "@test/plugin-alpha", + "version": "2.1.0", + "bin": { + "alpha-tool": "./dist/cli.js" + }, + "dependencies": { + "@test/plugin-beta": "workspace:^", + "react": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "typescript": "~5.3.0" + }, + "peerDependencies": { + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + }, + "optionalDependencies": { + "fsevents": "^2.3.0" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/plugins/plugin-beta/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/plugins/plugin-beta/package.json new file mode 100644 index 0000000..518a25d --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/input/workspace/plugins/plugin-beta/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/plugin-beta", + "version": "1.3.0", + "bin": "./dist/index.js", + "dependencies": { + "lodash": "^4.17.21" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/manifest.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/manifest.json new file mode 100644 index 0000000..7ec5cfe --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/manifest.json @@ -0,0 +1,47 @@ +{ + "packages": [ + { + "name": "@test/plugin-alpha", + "version": "2.1.0", + "path": "plugins/plugin-alpha/package.json", + "dependencies": { + "@test/plugin-beta": "workspace:^", + "react": "^18.0.0" + }, + "peerDependencies": { + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + }, + "optionalDependencies": { + "fsevents": "^2.3.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "typescript": "~5.3.0" + }, + "bin": { + "alpha-tool": "./dist/cli.js" + } + }, + { + "name": "@test/plugin-beta", + "version": "1.3.0", + "path": "plugins/plugin-beta/package.json", + "dependencies": { + "lodash": "^4.17.21" + }, + "bin": { + "plugin-beta": "./dist/index.js" + } + }, + { + "name": "@test/workspace-root", + "version": "1.0.0", + "path": "package.json" + } + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/package.json new file mode 100644 index 0000000..0136fd8 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*" + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/plugins/plugin-alpha/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/plugins/plugin-alpha/package.json new file mode 100644 index 0000000..fb49c94 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/plugins/plugin-alpha/package.json @@ -0,0 +1,26 @@ +{ + "name": "@test/plugin-alpha", + "version": "2.1.0", + "bin": { + "alpha-tool": "./dist/cli.js" + }, + "dependencies": { + "@test/plugin-beta": "workspace:^", + "react": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "typescript": "~5.3.0" + }, + "peerDependencies": { + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + }, + "optionalDependencies": { + "fsevents": "^2.3.0" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/plugins/plugin-beta/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/plugins/plugin-beta/package.json new file mode 100644 index 0000000..518a25d --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-no-backstage/output/workspace/plugins/plugin-beta/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/plugin-beta", + "version": "1.3.0", + "bin": "./dist/index.js", + "dependencies": { + "lodash": "^4.17.21" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/overlay/source.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/backstage.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/backstage.json new file mode 100644 index 0000000..6c12971 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/backstage.json @@ -0,0 +1,3 @@ +{ + "version": "1.42.5" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/package.json new file mode 100644 index 0000000..0136fd8 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*" + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/plugins/my-plugin/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/plugins/my-plugin/package.json new file mode 100644 index 0000000..a8becc3 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/plugins/my-plugin/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/my-plugin", + "version": "1.5.0", + "dependencies": { + "@backstage/core-plugin-api": "backstage:^", + "@backstage/theme": "backstage:^" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/yarn.lock b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/yarn.lock new file mode 100644 index 0000000..ce2da80 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/yarn.lock @@ -0,0 +1,33 @@ +__metadata: + version: 8 + cacheKey: 10c0 + +"@backstage/core-plugin-api@backstage:^": + version: 1.10.9 + resolution: "@backstage/core-plugin-api@npm:1.10.9" + dependencies: + "@backstage/types": "npm:^1.2.3" + react: "npm:^18.0.0" + peerDependencies: + react: "npm:^18.0.0" + bin: + backstage-core: ./dist/cli.js + checksum: abc123 + +"@backstage/theme@backstage:^": + version: 0.6.3 + resolution: "@backstage/theme@npm:0.6.3" + optionalDependencies: + "@emotion/react": "npm:^11.0.0" + peerDependencies: + react: "npm:^18.0.0" + peerDependenciesMeta: + react: + optional: true + checksum: def456 + +"@test/workspace-root@workspace:.": + version: 0.0.0-use.local + resolution: "@test/workspace-root@workspace:." + languageName: unknown + linkType: soft diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/backstage-manifest.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/backstage-manifest.json new file mode 100644 index 0000000..ca63d43 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/backstage-manifest.json @@ -0,0 +1,34 @@ +{ + "backstageVersion": "1.42.5", + "packages": [ + { + "name": "@backstage/core-plugin-api", + "version": "1.10.9", + "dependencies": { + "@backstage/types": "npm:^1.2.3", + "react": "npm:^18.0.0" + }, + "peerDependencies": { + "react": "npm:^18.0.0" + }, + "bin": { + "backstage-core": "./dist/cli.js" + } + }, + { + "name": "@backstage/theme", + "version": "0.6.3", + "peerDependencies": { + "react": "npm:^18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "optionalDependencies": { + "@emotion/react": "npm:^11.0.0" + } + } + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/backstage.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/backstage.json new file mode 100644 index 0000000..6c12971 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/backstage.json @@ -0,0 +1,3 @@ +{ + "version": "1.42.5" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/manifest.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/manifest.json new file mode 100644 index 0000000..9f42332 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/manifest.json @@ -0,0 +1,18 @@ +{ + "packages": [ + { + "name": "@test/my-plugin", + "version": "1.5.0", + "path": "plugins/my-plugin/package.json", + "dependencies": { + "@backstage/core-plugin-api": "backstage:^", + "@backstage/theme": "backstage:^" + } + }, + { + "name": "@test/workspace-root", + "version": "1.0.0", + "path": "package.json" + } + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/package.json new file mode 100644 index 0000000..0136fd8 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*" + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/plugins/my-plugin/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/plugins/my-plugin/package.json new file mode 100644 index 0000000..a8becc3 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/plugins/my-plugin/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/my-plugin", + "version": "1.5.0", + "dependencies": { + "@backstage/core-plugin-api": "backstage:^", + "@backstage/theme": "backstage:^" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/yarn.lock b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/yarn.lock new file mode 100644 index 0000000..ce2da80 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/output/workspace/yarn.lock @@ -0,0 +1,33 @@ +__metadata: + version: 8 + cacheKey: 10c0 + +"@backstage/core-plugin-api@backstage:^": + version: 1.10.9 + resolution: "@backstage/core-plugin-api@npm:1.10.9" + dependencies: + "@backstage/types": "npm:^1.2.3" + react: "npm:^18.0.0" + peerDependencies: + react: "npm:^18.0.0" + bin: + backstage-core: ./dist/cli.js + checksum: abc123 + +"@backstage/theme@backstage:^": + version: 0.6.3 + resolution: "@backstage/theme@npm:0.6.3" + optionalDependencies: + "@emotion/react": "npm:^11.0.0" + peerDependencies: + react: "npm:^18.0.0" + peerDependenciesMeta: + react: + optional: true + checksum: def456 + +"@test/workspace-root@workspace:.": + version: 0.0.0-use.local + resolution: "@test/workspace-root@workspace:." + languageName: unknown + linkType: soft diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/remote-backstage-manifest.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/remote-backstage-manifest.json new file mode 100644 index 0000000..47d329b --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/monorepo-with-backstage/remote-backstage-manifest.json @@ -0,0 +1,8 @@ +{ + "packages": [ + { "name": "@backstage/core-plugin-api", "version": "1.10.9" }, + { "name": "@backstage/theme", "version": "0.6.3" }, + { "name": "@backstage/types", "version": "1.2.3" }, + { "name": "@backstage/catalog-model", "version": "1.8.1" } + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/error b/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/error new file mode 100644 index 0000000..50fb61e --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/error @@ -0,0 +1 @@ +/backstage:.*dependencies but no backstage\.json/ \ No newline at end of file diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/overlay/source.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/workspace/package.json new file mode 100644 index 0000000..0136fd8 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*" + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/workspace/plugins/my-plugin/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/workspace/plugins/my-plugin/package.json new file mode 100644 index 0000000..c47611b --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/no-backstage-json-error/input/workspace/plugins/my-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/my-plugin", + "version": "1.0.0", + "dependencies": { + "@backstage/core-plugin-api": "backstage:^" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/error b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/error new file mode 100644 index 0000000..4bd5b61 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/error @@ -0,0 +1 @@ +/backstage version mismatch/ \ No newline at end of file diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/overlay/source.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/backstage.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/backstage.json new file mode 100644 index 0000000..6c12971 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/backstage.json @@ -0,0 +1,3 @@ +{ + "version": "1.42.5" +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/package.json new file mode 100644 index 0000000..0136fd8 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*" + ] +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/plugins/my-plugin/package.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/plugins/my-plugin/package.json new file mode 100644 index 0000000..c47611b --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/plugins/my-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/my-plugin", + "version": "1.0.0", + "dependencies": { + "@backstage/core-plugin-api": "backstage:^" + } +} diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/yarn.lock b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/yarn.lock new file mode 100644 index 0000000..01f9cf3 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/input/workspace/yarn.lock @@ -0,0 +1,8 @@ +__metadata: + version: 8 + cacheKey: 10c0 + +"@backstage/core-plugin-api@backstage:^": + version: 1.10.8 + resolution: "@backstage/core-plugin-api@npm:1.10.8" + checksum: stale123 diff --git a/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/remote-backstage-manifest.json b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/remote-backstage-manifest.json new file mode 100644 index 0000000..cf77e4e --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/__fixtures__/version-mismatch-error/remote-backstage-manifest.json @@ -0,0 +1,3 @@ +{ + "packages": [{ "name": "@backstage/core-plugin-api", "version": "1.10.9" }] +} diff --git a/prepare-sources/lib/modules/generate-manifests/index.test.ts b/prepare-sources/lib/modules/generate-manifests/index.test.ts new file mode 100644 index 0000000..6d7b5d9 --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/index.test.ts @@ -0,0 +1,364 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + loadFixture, + mockFetch, + type MockFetchResponse, + testInputOutputExpectations, +} from "../../test-utils.ts"; +import { + run, + extractBackstageEntries, + extractPackageInfo, + findWorkspacePackageJsonFiles, + getBackstageVersion, + normalizeBin, +} from "./index.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("generate-manifests", () => { + testInputOutputExpectations(import.meta.dirname, run, { + setup: (fixture, fixtureDir) => { + const releaseFile = path.join(fixtureDir, "remote-backstage-manifest.json"); + if (!fs.existsSync(releaseFile)) return; + + const backstageJsonPath = path.join(fixture.ctx.workspacePath, "backstage.json"); + if (!fs.existsSync(backstageJsonPath)) return; + + const version = JSON.parse(fs.readFileSync(backstageJsonPath, "utf8")).version; + const responseBody = JSON.parse(fs.readFileSync(releaseFile, "utf8")); + mockFetch({ + [`https://versions.backstage.io/v1/releases/${version}/manifest.json`]: responseBody, + }); + }, + }); + + it("logs package counts for workspace with backstage deps", async () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-with-backstage"); + const releaseFile = path.join( + import.meta.dirname, + "__fixtures__/monorepo-with-backstage/remote-backstage-manifest.json", + ); + const version = "1.42.5"; + mockFetch({ + [`https://versions.backstage.io/v1/releases/${version}/manifest.json`]: JSON.parse( + fs.readFileSync(releaseFile, "utf8"), + ), + }); + await run(fixture.ctx); + expect(fixture.ctx.log).toHaveBeenCalledWith(expect.stringContaining("Found")); + expect(fixture.ctx.log).toHaveBeenCalledWith(expect.stringContaining("Manifest written to:")); + expect(fixture.ctx.log).toHaveBeenCalledWith( + expect.stringContaining("Written: backstage-manifest.json"), + ); + }); + + it("throws when versions.backstage.io returns HTTP error", async () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-with-backstage"); + const version = "1.42.5"; + mockFetch({ + [`https://versions.backstage.io/v1/releases/${version}/manifest.json`]: { + body: null, + status: 503, + ok: false, + } satisfies MockFetchResponse, + }); + await expect(run(fixture.ctx)).rejects.toThrow(/HTTP 503/); + }); + + it("throws when manifest response has no packages array", async () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-with-backstage"); + const version = "1.42.5"; + mockFetch({ + [`https://versions.backstage.io/v1/releases/${version}/manifest.json`]: { + releaseVersion: version, + }, + }); + await expect(run(fixture.ctx)).rejects.toThrow(/invalid Backstage manifest response/); + }); + + it("skips malformed entries in manifest packages array", async () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-with-backstage"); + const version = "1.42.5"; + mockFetch({ + [`https://versions.backstage.io/v1/releases/${version}/manifest.json`]: { + packages: [ + null, + 42, + { name: "@backstage/core-plugin-api", version: "1.10.9" }, + { name: "@backstage/theme", version: "0.6.3" }, + { name: "@backstage/types", version: "1.2.3" }, + { name: "@backstage/catalog-model", version: "1.8.1" }, + { noVersion: true }, + { name: 123, version: "1.0.0" }, + ], + }, + }); + await run(fixture.ctx); + expect(fixture.ctx.log).toHaveBeenCalledWith(expect.stringContaining("Found")); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests for extractBackstageEntries +// --------------------------------------------------------------------------- + +describe("extractBackstageEntries", () => { + it("extracts all field types from a complete lockfile", () => { + const lockContent = ` +"react@npm:^18.0.0": + version: 18.3.1 + resolution: "react@npm:18.3.1" + checksum: def + +"@backstage/core-plugin-api@backstage:^": + version: 1.10.9 + resolution: "@backstage/core-plugin-api@npm:1.10.9" + dependencies: + "@backstage/types": "npm:^1.2.3" + bin: + backstage-cli: ./bin/backstage-cli + checksum: abc123 + +"@backstage/theme@backstage:^": + version: 0.6.3 + resolution: "@backstage/theme@npm:0.6.3" + peerDependencies: + react: "npm:^18.0.0" + peerDependenciesMeta: + react: + optional: true + @emotion/react: + reason: styling + optional: false + optionalDependencies: + "@emotion/react": "npm:^11.0.0" + checksum: xyz789 +`; + const entries = extractBackstageEntries(lockContent); + expect(entries).toHaveLength(2); + + expect(entries[0]).toEqual({ + name: "@backstage/core-plugin-api", + version: "1.10.9", + dependencies: { "@backstage/types": "npm:^1.2.3" }, + bin: { "backstage-cli": "./bin/backstage-cli" }, + }); + + expect(entries[1]).toEqual({ + name: "@backstage/theme", + version: "0.6.3", + peerDependencies: { react: "npm:^18.0.0" }, + peerDependenciesMeta: { + react: { optional: true }, + "@emotion/react": { reason: "styling", optional: false }, + }, + optionalDependencies: { "@emotion/react": "npm:^11.0.0" }, + }); + }); + + it("skips entries with malformed descriptor, missing version, or no backstage deps", () => { + const lockContent = ` +"react@npm:^18.0.0": + version: 18.3.1 + checksum: def + +"backstage:^": + version: 1.0.0 + resolution: "something@npm:1.0.0" + checksum: abc + +"@backstage/core-plugin-api@backstage:^": + resolution: "@backstage/core-plugin-api@npm:1.10.9" + checksum: abc +`; + expect(extractBackstageEntries(lockContent)).toEqual([]); + }); + + it("omits empty dependency and peerDependenciesMeta sections", () => { + const lockContent = ` +"@backstage/core-plugin-api@backstage:^": + version: 1.10.9 + resolution: "@backstage/core-plugin-api@npm:1.10.9" + dependencies: + peerDependenciesMeta: + checksum: abc +`; + const entries = extractBackstageEntries(lockContent); + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(entry).toBeDefined(); + expect(entry?.dependencies).toBeUndefined(); + expect(entry?.peerDependenciesMeta).toBeUndefined(); + }); + + it("omits dependency fields when all map entries are malformed", () => { + const lockContent = [ + '"@backstage/core-plugin-api@backstage:^":', + " version: 1.10.9", + ' resolution: "@backstage/core-plugin-api@npm:1.10.9"', + " dependencies:", + " no-colon-garbage", + " also-no-colon-garbage", + " checksum: abc", + ].join("\n"); + const entries = extractBackstageEntries(lockContent); + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(entry).toBeDefined(); + expect(entry?.dependencies).toBeUndefined(); + }); + + it("omits peerDependenciesMeta when all nested-map keys are malformed", () => { + const lockContent = [ + '"@backstage/core-plugin-api@backstage:^":', + " version: 1.10.9", + ' resolution: "@backstage/core-plugin-api@npm:1.10.9"', + " peerDependenciesMeta:", + " not-a-key-header 1.0.0", + " orphan-prop: true", + " checksum: abc", + ].join("\n"); + const entries = extractBackstageEntries(lockContent); + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(entry).toBeDefined(); + expect(entry?.peerDependenciesMeta).toBeUndefined(); + }); + + it("handles malformed lockfile content gracefully", () => { + const lockContent = [ + '"@backstage/core-plugin-api@backstage:^":', + " version: 1.10.9", + ' resolution: "@backstage/core-plugin-api@npm:1.10.9"', + " dependencies:", + " !!!not-a-valid-line!!!", + ' "@backstage/types": "npm:^1.2.3"', + " peerDependenciesMeta:", + " orphan-field: true", + " react:", + " optional: true", + " ", + " checksum: abc", + ].join("\n"); + const entries = extractBackstageEntries(lockContent); + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(entry).toBeDefined(); + expect(entry?.dependencies).toEqual({ "@backstage/types": "npm:^1.2.3" }); + expect(entry?.peerDependenciesMeta).toEqual({ react: { optional: true } }); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests for normalizeBin +// --------------------------------------------------------------------------- + +describe("normalizeBin", () => { + const cases: Array< + [string, string, string | Record, Record | undefined] + > = [ + ["scoped string → object", "@scope/my-cli", "./dist/cli.js", { "my-cli": "./dist/cli.js" }], + ["unscoped string → object", "my-tool", "./cli.js", { "my-tool": "./cli.js" }], + [ + "object passthrough", + "@scope/pkg", + { "tool-a": "./a.js", "tool-b": "./b.js" }, + { "tool-a": "./a.js", "tool-b": "./b.js" }, + ], + ["empty object → undefined", "@scope/pkg", {}, undefined], + ]; + + it.each(cases)("%s", (_label, name, bin, expected) => { + expect(normalizeBin(name, bin)).toEqual(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests for findWorkspacePackageJsonFiles +// --------------------------------------------------------------------------- + +describe("findWorkspacePackageJsonFiles", () => { + it("finds root package.json for flat repo (no workspaces field)", () => { + using fixture = loadFixture(import.meta.dirname, "flat-repo-single-package"); + const files = findWorkspacePackageJsonFiles(fixture.ctx.workspacePath); + expect(files).toHaveLength(1); + expect(files[0]).toContain("package.json"); + }); + + it("finds root + workspace members for monorepo", () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-no-backstage"); + const files = findWorkspacePackageJsonFiles(fixture.ctx.workspacePath); + expect(files).toHaveLength(3); + }); + + it("handles edge cases: missing root, dist-dynamic exclusion, empty glob match", () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-no-backstage"); + const wp = fixture.ctx.workspacePath; + + fs.mkdirSync(path.join(wp, "plugins", "empty-dir"), { recursive: true }); + expect(findWorkspacePackageJsonFiles(wp)).toHaveLength(3); + + const distDir = path.join(wp, "plugins", "dist-dynamic"); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync( + path.join(distDir, "package.json"), + JSON.stringify({ name: "should-be-excluded", version: "0.0.0" }), + ); + expect(findWorkspacePackageJsonFiles(wp)).toHaveLength(3); + expect(findWorkspacePackageJsonFiles(wp).every((f) => !f.includes("dist-dynamic"))).toBe(true); + + fs.unlinkSync(path.join(wp, "package.json")); + expect(findWorkspacePackageJsonFiles(wp)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests for getBackstageVersion +// --------------------------------------------------------------------------- + +describe("getBackstageVersion", () => { + it("reads version from backstage.json", () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-with-backstage"); + expect(getBackstageVersion(fixture.ctx.workspacePath)).toBe("1.42.5"); + }); + + it("throws when backstage.json is missing", () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-no-backstage"); + expect(() => getBackstageVersion(fixture.ctx.workspacePath)).toThrow(/no backstage\.json/); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests for extractPackageInfo +// --------------------------------------------------------------------------- + +describe("extractPackageInfo", () => { + it("returns null for missing name or version, filtering them from manifest", async () => { + using fixture = loadFixture(import.meta.dirname, "monorepo-no-backstage"); + const rootPkgPath = path.join(fixture.ctx.workspacePath, "package.json"); + const content = JSON.parse(fs.readFileSync(rootPkgPath, "utf8")); + + fs.writeFileSync(rootPkgPath, JSON.stringify({ ...content, name: undefined })); + expect(extractPackageInfo(rootPkgPath, fixture.ctx.workspacePath)).toBeNull(); + + fs.writeFileSync(rootPkgPath, JSON.stringify({ ...content, version: undefined })); + expect(extractPackageInfo(rootPkgPath, fixture.ctx.workspacePath)).toBeNull(); + + fs.writeFileSync(rootPkgPath, JSON.stringify(content)); + const alphaPath = path.join( + fixture.ctx.workspacePath, + "plugins", + "plugin-alpha", + "package.json", + ); + fs.writeFileSync(alphaPath, JSON.stringify({ version: "1.0.0" })); + await run(fixture.ctx); + expect(fixture.ctx.log).toHaveBeenCalledWith(expect.stringMatching(/Found 2 local packages/)); + }); +}); diff --git a/prepare-sources/lib/modules/generate-manifests/index.ts b/prepare-sources/lib/modules/generate-manifests/index.ts new file mode 100644 index 0000000..aeda39e --- /dev/null +++ b/prepare-sources/lib/modules/generate-manifests/index.ts @@ -0,0 +1,365 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { ModuleContext } from "../../pipeline.ts"; +import type { + BackstageManifest, + BackstagePackageEntry, + WorkspaceManifest, + WorkspacePackageEntry, +} from "../../manifest-types.ts"; +import { + type LockfileBlock, + getMap, + getNestedMap, + getScalar, + parseLockfile, +} from "../../yarn-lock-parser.ts"; + +const VERSIONS_BACKSTAGE_IO = "https://versions.backstage.io/v1/releases"; + +const EXCLUDE_DIRS = new Set(["node_modules", "dist-dynamic", "dist-scalprum", "dist", "build"]); + +/** + * Generates `manifest.json` (workspace package inventory) and optionally + * `backstage-manifest.json` (Backstage release packages with dependency + * metadata, extracted from `yarn.lock` and validated against + * `versions.backstage.io`). + * + * Must run before scrubbing so that versions of to-be-scrubbed packages are + * captured. Not included in the OCI artifact. + */ +export async function run(ctx: ModuleContext): Promise { + ctx.log("Scanning for package.json files..."); + const workspaceManifest = generateWorkspaceManifest(ctx); + + ctx.log(`Found ${workspaceManifest.packages.length} local packages`); + for (const pkg of workspaceManifest.packages) { + ctx.log(` - ${pkg.name}@${pkg.version}`); + } + + const manifestPath = path.join(ctx.workspacePath, "manifest.json"); + writeJson(manifestPath, workspaceManifest); + ctx.log(`Manifest written to: manifest.json`); + + if (!manifestHasBackstageDeps(workspaceManifest)) { + ctx.log("No backstage:^ dependencies found, skipping"); + return; + } + + const backstageManifest = await generateBackstageManifest(ctx); + const backstagePath = path.join(ctx.workspacePath, "backstage-manifest.json"); + writeJson(backstagePath, backstageManifest); + ctx.log(`Written: backstage-manifest.json (${backstageManifest.packages.length} packages)`); +} + +function manifestHasBackstageDeps(manifest: WorkspaceManifest): boolean { + return manifest.packages.some((pkg) => { + const allDeps = [ + pkg.dependencies, + pkg.devDependencies, + pkg.peerDependencies, + pkg.optionalDependencies, + ]; + return allDeps.some((deps) => deps && Object.values(deps).includes("backstage:^")); + }); +} + +// --------------------------------------------------------------------------- +// Workspace manifest +// --------------------------------------------------------------------------- + +function generateWorkspaceManifest(ctx: ModuleContext): WorkspaceManifest { + const packageJsonPaths = findWorkspacePackageJsonFiles(ctx.workspacePath); + const packages: WorkspacePackageEntry[] = []; + + for (const pkgPath of packageJsonPaths) { + const entry = extractPackageInfo(pkgPath, ctx.workspacePath); + if (entry) packages.push(entry); + } + + packages.sort((a, b) => a.name.localeCompare(b.name)); + return { packages }; +} + +/** + * Discover workspace package.json files using the `workspaces` field from + * the root package.json, filtered to exclude build output directories. + * For flat repos (no `workspaces` field), returns just the root. + */ +export function findWorkspacePackageJsonFiles(workspacePath: string): string[] { + const rootPkgPath = path.join(workspacePath, "package.json"); + if (!fs.existsSync(rootPkgPath)) return []; + + const rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf8")); + const patterns: string[] = rootPkg.workspaces ?? []; + const results = [rootPkgPath]; + + for (const pattern of patterns) { + const matches = fs.globSync(pattern, { cwd: workspacePath }); + for (const match of matches) { + if (match.split(path.sep).some((seg) => EXCLUDE_DIRS.has(seg))) continue; + const pkgPath = path.join(workspacePath, match, "package.json"); + if (fs.existsSync(pkgPath)) results.push(pkgPath); + } + } + + return results; +} + +const DEP_FIELDS = [ + "dependencies", + "peerDependencies", + "peerDependenciesMeta", + "optionalDependencies", + "devDependencies", +] as const; + +export function extractPackageInfo( + packageJsonPath: string, + baseDir: string, +): WorkspacePackageEntry | null { + const content = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (!content.name || !content.version) return null; + + const entry: WorkspacePackageEntry = { + name: content.name, + version: content.version, + path: path.relative(path.resolve(baseDir), packageJsonPath), + }; + + for (const field of DEP_FIELDS) { + if (content[field] && Object.keys(content[field]).length > 0) { + (entry as Record)[field] = content[field]; + } + } + + if (content.bin) { + entry.bin = normalizeBin(content.name, content.bin); + } + + return entry; +} + +/** + * Normalize the `bin` field to always be an object. + * String form `"./cli.js"` becomes `{ "": "./cli.js" }`. + */ +export function normalizeBin( + packageName: string, + bin: string | Record, +): Record | undefined { + if (typeof bin === "string") { + const slashPos = packageName.lastIndexOf("/"); + const unscopedName = slashPos === -1 ? packageName : packageName.substring(slashPos + 1); + return { [unscopedName]: bin }; + } + if (Object.keys(bin).length > 0) return bin; + return undefined; +} + +// --------------------------------------------------------------------------- +// Backstage manifest +// --------------------------------------------------------------------------- + +async function generateBackstageManifest(ctx: ModuleContext): Promise { + const backstageVersion = getBackstageVersion(ctx.workspacePath); + ctx.log(`Backstage version: ${backstageVersion}`); + + const yarnLockPath = path.join(ctx.workspacePath, "yarn.lock"); + if (!fs.existsSync(yarnLockPath)) { + throw new Error("yarn.lock not found — cannot extract backstage package metadata"); + } + + const lockContent = fs.readFileSync(yarnLockPath, "utf8"); + const entries = extractBackstageEntries(lockContent); + ctx.log(`Extracted ${entries.length} @backstage/* entries from yarn.lock`); + + await validateBackstageVersions(entries, backstageVersion, ctx); + + return { + backstageVersion, + packages: entries.toSorted((a, b) => a.name.localeCompare(b.name)), + }; +} + +/** + * Read the Backstage version from `backstage.json` in the workspace. + * Throws if `backstage:^` deps exist but no `backstage.json` is found. + */ +export function getBackstageVersion(workspacePath: string): string { + const backstageJsonPath = path.join(workspacePath, "backstage.json"); + if (!fs.existsSync(backstageJsonPath)) { + throw new Error( + "workspace has backstage:^ dependencies but no backstage.json — " + + "cannot determine Backstage version", + ); + } + const content = JSON.parse(fs.readFileSync(backstageJsonPath, "utf8")); + if (!content.version) { + throw new Error("backstage.json exists but has no 'version' field"); + } + return content.version; +} + +// --------------------------------------------------------------------------- +// yarn.lock extraction (using shared parser) +// --------------------------------------------------------------------------- + +/** + * Extract `@backstage/*` package entries resolved via `backstage:^` from a + * yarn.lock string. Returns structured entries with version and dependency + * metadata. + * + * Uses the shared `yarn-lock-parser` for structural parsing, then maps the + * parsed blocks to `BackstagePackageEntry` objects. + */ +export function extractBackstageEntries(lockContent: string): BackstagePackageEntry[] { + const lockfile = parseLockfile(lockContent); + return lockfile.blocks + .map(parseBackstageBlock) + .filter((entry): entry is BackstagePackageEntry => entry !== undefined); +} + +function parseBackstageBlock(block: LockfileBlock): BackstagePackageEntry | undefined { + const descriptor = block.descriptors.find((d) => d.includes("backstage:^")); + if (!descriptor) return undefined; + + const nameMatch = /(@backstage\/[^@]+)/.exec(descriptor); + const name = nameMatch?.[1]; + if (!name) return undefined; + + const version = getScalar(block, "version"); + if (!version) return undefined; + + const entry: BackstagePackageEntry = { name, version }; + + const deps = getStrippedMap(block, "dependencies"); + if (deps) entry.dependencies = deps; + + const peers = getStrippedMap(block, "peerDependencies"); + if (peers) entry.peerDependencies = peers; + + const peersMeta = getNestedMap(block, "peerDependenciesMeta"); + if (peersMeta) { + entry.peerDependenciesMeta = convertNestedMapValues(peersMeta); + } + + const optional = getStrippedMap(block, "optionalDependencies"); + if (optional) entry.optionalDependencies = optional; + + const bin = getStrippedMap(block, "bin"); + if (bin) entry.bin = bin; + + return entry; +} + +function stripMapQuotes(raw: { [key: string]: string }): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + result[key.replace(/^"|"$/g, "")] = value.replace(/^"|"$/g, ""); + } + return result; +} + +function getStrippedMap( + block: LockfileBlock, + fieldName: string, +): Record | undefined { + const raw = getMap(block, fieldName); + if (!raw) return undefined; + const stripped = stripMapQuotes(raw); + return Object.keys(stripped).length > 0 ? stripped : undefined; +} + +function convertNestedMapValues(peersMeta: { + [key: string]: { [prop: string]: string }; +}): Record> | undefined { + const entries = Object.entries(peersMeta); + if (entries.length === 0) return undefined; + const converted: Record> = {}; + for (const [pkg, props] of entries) { + const record: Record = {}; + converted[pkg] = record; + for (const [key, val] of Object.entries(props)) { + const unquoted = val.replace(/^"|"$/g, ""); + let value: unknown; + if (unquoted === "true") { + value = true; + } else if (unquoted === "false") { + value = false; + } else { + value = unquoted; + } + record[key] = value; + } + } + return converted; +} + +// --------------------------------------------------------------------------- +// Backstage version validation +// --------------------------------------------------------------------------- + +async function validateBackstageVersions( + entries: BackstagePackageEntry[], + backstageVersion: string, + ctx: ModuleContext, +): Promise { + const url = `${VERSIONS_BACKSTAGE_IO}/${backstageVersion}/manifest.json`; + ctx.log(`Validating against ${url}`); + + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `failed to fetch Backstage manifest for ${backstageVersion}: HTTP ${response.status}`, + ); + } + + const manifest: unknown = await response.json(); + if ( + typeof manifest !== "object" || + manifest === null || + !("packages" in manifest) || + !Array.isArray(manifest.packages) + ) { + throw new Error(`invalid Backstage manifest response from ${url}`); + } + const versionMap = new Map(); + for (const pkg of manifest.packages) { + if ( + typeof pkg === "object" && + pkg !== null && + "name" in pkg && + "version" in pkg && + typeof pkg.name === "string" && + typeof pkg.version === "string" + ) { + versionMap.set(pkg.name, pkg.version); + } + } + + const mismatches: string[] = []; + for (const entry of entries) { + const expected = versionMap.get(entry.name); + if (expected && expected !== entry.version) { + mismatches.push(`${entry.name}: yarn.lock has ${entry.version}, manifest has ${expected}`); + } + } + + if (mismatches.length > 0) { + throw new Error( + `backstage version mismatch between yarn.lock and versions.backstage.io:\n` + + mismatches.join("\n"), + ); + } + + ctx.log(`All ${entries.length} entries validated against Backstage ${backstageVersion}`); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function writeJson(filePath: string, data: unknown): void { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n"); +} diff --git a/prepare-sources/lib/test-utils.ts b/prepare-sources/lib/test-utils.ts index fcce6fe..99d94b6 100644 --- a/prepare-sources/lib/test-utils.ts +++ b/prepare-sources/lib/test-utils.ts @@ -5,6 +5,68 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { readSourceFile } from "./source.ts"; import type { ModuleContext } from "./pipeline.ts"; +// --------------------------------------------------------------------------- +// Fetch mocking +// --------------------------------------------------------------------------- + +export type MockFetchResponse = { + body: unknown; + status?: number; + ok?: boolean; +}; + +function isMockFetchResponse(value: object): value is MockFetchResponse { + return "body" in value; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Stub global `fetch` with a URL → response mapping. + * + * Values can be plain objects (treated as 200 OK JSON bodies) or + * `MockFetchResponse` objects for control over status/ok. + * + * Unmocked URLs cause the returned `fetch` to throw, making tests fail fast + * if unexpected network calls happen. Call `vi.restoreAllMocks()` (or rely on + * the `afterEach` below) to restore the real `fetch`. + */ +export function mockFetch( + mappings: Record, + options?: { unmockedBehavior?: "throw" | "empty" }, +): void { + const behavior = options?.unmockedBehavior ?? "throw"; + vi.stubGlobal("fetch", (input: string | URL | Request): Promise => { + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else { + url = input.url; + } + if (url in mappings) { + const raw = mappings[url]; + if (raw === undefined) { + return Promise.reject(new Error(`unmocked fetch: ${url}`)); + } + if (isMockFetchResponse(raw)) { + return Promise.resolve(jsonResponse(raw.body, raw.status ?? 200)); + } + return Promise.resolve(jsonResponse(raw)); + } + if (behavior === "empty") { + return Promise.resolve(jsonResponse(null, 404)); + } + return Promise.reject(new Error(`unmocked fetch: ${url}`)); + }); +} + /** Assertion helpers scoped to a single directory. */ export interface DirAssertions { readFile(relativePath: string): string; @@ -209,10 +271,15 @@ export function loadFixture( * * @param testDir Pass `import.meta.dirname` from your test file. * @param run The module's `run` function. + * @param options Optional hooks: + * - `setup(fixture, fixtureDir)` — called before each fixture's module run. + * Use for per-fixture mocking (e.g. `mockFetch` from network response + * fixture files). Cleanup is handled by `afterEach` / `vi.restoreAllMocks`. */ export function testInputOutputExpectations( testDir: string, run: (ctx: ModuleContext) => Promise, + options?: { setup?: (fixture: ModuleFixture, fixtureDir: string) => void }, ): void { const fixturesDir = path.join(testDir, "__fixtures__"); if (!fs.existsSync(fixturesDir)) { @@ -228,8 +295,10 @@ export function testInputOutputExpectations( describe("fixtures", () => { for (const name of fixtures) { it(name, async () => { + const fixtureDir = path.join(fixturesDir, name); using fixture = loadFixture(testDir, name); - const errorFile = path.join(fixturesDir, name, "error"); + options?.setup?.(fixture, fixtureDir); + const errorFile = path.join(fixtureDir, "error"); if (fs.existsSync(errorFile)) { const raw = fs.readFileSync(errorFile, "utf8").trim(); diff --git a/prepare-sources/lib/yarn-lock-parser.test.ts b/prepare-sources/lib/yarn-lock-parser.test.ts new file mode 100644 index 0000000..94689e9 --- /dev/null +++ b/prepare-sources/lib/yarn-lock-parser.test.ts @@ -0,0 +1,953 @@ +import { describe, expect, it } from "vitest"; +import { + type Lockfile, + type LockfileBlock, + getMap, + getNestedMap, + getScalar, + parseLockfile, + serializeBlock, + serializeLockfile, +} from "./yarn-lock-parser.ts"; + +function roundTrip(lockfile: Lockfile): string { + return serializeLockfile(lockfile); +} + +function expectFirstBlock(lockfile: Lockfile): LockfileBlock { + const block = lockfile.blocks[0]; + expect(block).toBeDefined(); + if (!block) { + throw new Error("expected block"); + } + return block; +} + +// --------------------------------------------------------------------------- +// Round-trip fidelity +// --------------------------------------------------------------------------- + +describe("round-trip fidelity", () => { + const cases: [string, string][] = [ + [ + "simple lockfile", + [ + '# This file is generated by running "yarn install" inside your project.', + "# Manual changes might be lost - proceed with caution!", + "", + "__metadata:", + " version: 8", + " cacheKey: 10c0", + "", + '"@backstage/core-plugin-api@backstage:^":', + " version: 1.10.9", + ' resolution: "@backstage/core-plugin-api@npm:1.10.9"', + " dependencies:", + ' "@backstage/types": "npm:^1.2.3"', + ' react: "npm:^18.0.0"', + " checksum: abc123", + " languageName: node", + " linkType: hard", + "", + ].join("\n"), + ], + [ + "combined keys", + [ + "__metadata:", + " version: 8", + "", + '"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.347.0, @aws-sdk/types@npm:^3.973.1":', + " version: 3.973.1", + ' resolution: "@aws-sdk/types@npm:3.973.1"', + " checksum: abc", + "", + ].join("\n"), + ], + [ + "nested map (peerDependenciesMeta)", + [ + "__metadata:", + " version: 8", + "", + '"@backstage/theme@backstage:^":', + " version: 0.6.3", + " peerDependencies:", + ' react: "npm:^18.0.0"', + " peerDependenciesMeta:", + " react:", + " optional: true", + " linkType: hard", + "", + ].join("\n"), + ], + [ + "unquoted simple keys in deps", + [ + "__metadata:", + " version: 8", + "", + '"ajv@npm:^8.17.1":', + " version: 8.17.1", + " dependencies:", + ' fast-deep-equal: "npm:^3.1.3"', + ' json-schema-traverse: "npm:^1.0.0"', + ' require-from-string: "npm:^2.0.2"', + ' uri-js: "npm:^4.4.1"', + " linkType: hard", + "", + ].join("\n"), + ], + [ + "workspace block", + [ + "__metadata:", + " version: 8", + "", + '"@test/root@workspace:.":', + " version: 0.0.0-use.local", + ' resolution: "@test/root@workspace:."', + " languageName: unknown", + " linkType: soft", + "", + ].join("\n"), + ], + [ + "block with bin", + [ + "__metadata:", + " version: 8", + "", + '"@backstage/core-plugin-api@backstage:^":', + " version: 1.10.9", + " bin:", + " backstage-core: ./dist/cli.js", + " checksum: abc123", + "", + ].join("\n"), + ], + [ + "multiple blocks", + [ + "__metadata:", + " version: 8", + "", + '"pkg-a@npm:^1.0.0":', + " version: 1.0.0", + " linkType: hard", + "", + '"pkg-b@npm:^2.0.0":', + " version: 2.0.0", + " dependencies:", + ' pkg-a: "npm:^1.0.0"', + " linkType: hard", + "", + ].join("\n"), + ], + ]; + + it.each(cases)("%s", (_name, input) => { + const lockfile = parseLockfile(input); + const output = serializeLockfile(lockfile); + expect(output).toBe(input); + }); +}); + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +describe("parseLockfile", () => { + it("captures the preamble including __metadata", () => { + const input = [ + "# comment", + "", + "__metadata:", + " version: 8", + " cacheKey: 10c0", + "", + '"pkg@npm:^1.0.0":', + " version: 1.0.0", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + expect(lockfile.preamble).toBe("# comment\n\n__metadata:\n version: 8\n cacheKey: 10c0"); + expect(lockfile.blocks).toHaveLength(1); + }); + + it("handles lockfile without __metadata", () => { + const input = ['"pkg@npm:^1.0.0":', " version: 1.0.0", ""].join("\n"); + + const lockfile = parseLockfile(input); + expect(lockfile.preamble).toBe(""); + expect(lockfile.blocks).toHaveLength(1); + const block = expectFirstBlock(lockfile); + expect(block.fields.version).toEqual({ + kind: "scalar", + raw: "1.0.0", + }); + }); + + it("parses combined descriptor keys", () => { + const input = [ + "__metadata:", + " version: 8", + "", + '"@aws/types@npm:^3.0.0, @aws/types@npm:^3.1.0":', + " version: 3.1.0", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.descriptors).toEqual(["@aws/types@npm:^3.0.0", "@aws/types@npm:^3.1.0"]); + }); + + it("parses scalar, map, and nested-map fields", () => { + const input = [ + "__metadata:", + " version: 8", + "", + '"@backstage/theme@backstage:^":', + " version: 0.6.3", + ' resolution: "@backstage/theme@npm:0.6.3"', + " dependencies:", + ' "@emotion/react": "npm:^11.0.0"', + " peerDependencies:", + ' react: "npm:^18.0.0"', + " peerDependenciesMeta:", + " react:", + " optional: true", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + + expect(block.fields.version).toEqual({ kind: "scalar", raw: "0.6.3" }); + expect(block.fields.resolution).toEqual({ + kind: "scalar", + raw: '"@backstage/theme@npm:0.6.3"', + }); + expect(block.fields.dependencies).toEqual({ + kind: "map", + entries: { '"@emotion/react"': '"npm:^11.0.0"' }, + }); + expect(block.fields.peerDependencies).toEqual({ + kind: "map", + entries: { react: '"npm:^18.0.0"' }, + }); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { react: { optional: "true" } }, + }); + expect(block.fields.linkType).toEqual({ kind: "scalar", raw: "hard" }); + }); + + it("skips blank lines between blocks", () => { + const input = [ + "__metadata:", + " version: 8", + "", + "", + '"a@npm:1":', + " version: 1", + "", + "", + "", + '"b@npm:2":', + " version: 2", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + expect(lockfile.blocks).toHaveLength(2); + }); + + it("skips stray non-descriptor lines", () => { + const input = [ + "__metadata:", + " version: 8", + "", + "this is a stray line without trailing colon", + "", + '"pkg@npm:1":', + " version: 1", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + expect(lockfile.blocks).toHaveLength(1); + const block = expectFirstBlock(lockfile); + expect(block.descriptors).toEqual(["pkg@npm:1"]); + }); + + it("handles unquoted descriptor (non-standard)", () => { + const input = ["my-pkg@npm:1.0.0:", " version: 1.0.0", ""].join("\n"); + + const lockfile = parseLockfile(input); + expect(lockfile.blocks).toHaveLength(1); + const block = expectFirstBlock(lockfile); + expect(block.descriptors).toEqual(["my-pkg@npm:1.0.0"]); + }); + + it("skips mid-file comment lines", () => { + const input = [ + "__metadata:", + " version: 8", + "", + "# Inline comment between blocks", + '"pkg@npm:1":', + " version: 1", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + expect(lockfile.blocks).toHaveLength(1); + }); + + it("handles nested-map with orphan property before key", () => { + const input = [ + "__metadata:", + " version: 8", + "", + '"pkg@npm:1":', + " version: 1", + " peerDependenciesMeta:", + " orphan: true", + " react:", + " optional: true", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { react: { optional: "true" } }, + }); + expect(block.fields.linkType).toEqual({ kind: "scalar", raw: "hard" }); + }); +}); + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +describe("serializeBlock", () => { + it("quotes all descriptors", () => { + const block: LockfileBlock = { + descriptors: ["pkg@npm:^1.0.0"], + fields: { version: { kind: "scalar", raw: "1.0.0" } }, + }; + const output = serializeBlock(block); + expect(output).toBe('"pkg@npm:^1.0.0":\n version: 1.0.0'); + }); + + it("serializes combined descriptors within one pair of quotes", () => { + const block: LockfileBlock = { + descriptors: ["pkg@npm:^1.0.0", "pkg@npm:^1.1.0"], + fields: { version: { kind: "scalar", raw: "1.1.0" } }, + }; + const output = serializeBlock(block); + expect(output.startsWith('"pkg@npm:^1.0.0, pkg@npm:^1.1.0":')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Convenience accessors +// --------------------------------------------------------------------------- + +describe("getScalar", () => { + it("returns unquoted value", () => { + const block: LockfileBlock = { + descriptors: ["pkg@npm:1"], + fields: { + version: { kind: "scalar", raw: "1.0.0" }, + resolution: { kind: "scalar", raw: '"pkg@npm:1.0.0"' }, + }, + }; + expect(getScalar(block, "version")).toBe("1.0.0"); + expect(getScalar(block, "resolution")).toBe("pkg@npm:1.0.0"); + }); + + it("returns undefined for missing or non-scalar field", () => { + const block: LockfileBlock = { + descriptors: ["pkg@npm:1"], + fields: { + deps: { kind: "map", entries: { a: "1" } }, + }, + }; + expect(getScalar(block, "missing")).toBeUndefined(); + expect(getScalar(block, "deps")).toBeUndefined(); + }); +}); + +describe("getMap", () => { + it("returns entries", () => { + const block: LockfileBlock = { + descriptors: ["pkg@npm:1"], + fields: { + dependencies: { + kind: "map", + entries: { react: '"npm:^18.0.0"' }, + }, + }, + }; + expect(getMap(block, "dependencies")).toEqual({ + react: '"npm:^18.0.0"', + }); + }); + + it("returns undefined for missing or non-map field", () => { + const block: LockfileBlock = { + descriptors: ["pkg@npm:1"], + fields: { + version: { kind: "scalar", raw: "1" }, + }, + }; + expect(getMap(block, "missing")).toBeUndefined(); + expect(getMap(block, "version")).toBeUndefined(); + }); +}); + +describe("getNestedMap", () => { + it("returns entries", () => { + const block: LockfileBlock = { + descriptors: ["pkg@npm:1"], + fields: { + peerDependenciesMeta: { + kind: "nested-map", + entries: { react: { optional: "true" } }, + }, + }, + }; + expect(getNestedMap(block, "peerDependenciesMeta")).toEqual({ + react: { optional: "true" }, + }); + }); + + it("returns undefined for missing or non-nested-map field", () => { + const block: LockfileBlock = { + descriptors: ["pkg@npm:1"], + fields: { + version: { kind: "scalar", raw: "1" }, + }, + }; + expect(getNestedMap(block, "missing")).toBeUndefined(); + expect(getNestedMap(block, "version")).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Mutation scenarios (for protocol-resolution) +// --------------------------------------------------------------------------- + +describe("mutation", () => { + it("allows updating a scalar value while preserving order", () => { + const input = [ + "__metadata:", + " version: 8", + "", + '"pkg@npm:^1.0.0":', + " version: 1.0.0", + ' resolution: "pkg@npm:1.0.0"', + " checksum: abc", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + block.fields.version = { kind: "scalar", raw: "2.0.0" }; + + expect(roundTrip(lockfile)).toBe(input.replace("version: 1.0.0", "version: 2.0.0")); + }); + + it("allows replacing a dependency value in a map", () => { + const input = [ + "__metadata:", + " version: 8", + "", + '"pkg@npm:^1.0.0":', + " version: 1.0.0", + " dependencies:", + ' react: "npm:^17.0.0"', + ' lodash: "npm:^4.0.0"', + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + const deps = block.fields.dependencies; + if (deps?.kind === "map") { + deps.entries.react = '"npm:^18.0.0"'; + } + + const output = roundTrip(lockfile); + expect(output).toContain('react: "npm:^18.0.0"'); + expect(output).toContain('lodash: "npm:^4.0.0"'); + expect(output.indexOf("react")).toBeLessThan(output.indexOf("lodash")); + }); + + it("allows adding a new block", () => { + const input = [ + "__metadata:", + " version: 8", + "", + '"pkg-a@npm:^1.0.0":', + " version: 1.0.0", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + lockfile.blocks.push({ + descriptors: ["pkg-b@npm:^2.0.0"], + fields: { + version: { kind: "scalar", raw: "2.0.0" }, + linkType: { kind: "scalar", raw: "hard" }, + }, + }); + + const output = roundTrip(lockfile); + expect(output).toContain('"pkg-b@npm:^2.0.0":'); + expect(output).toContain("version: 2.0.0"); + }); + + it("allows removing a block", () => { + const input = [ + "__metadata:", + " version: 8", + "", + '"pkg-a@npm:^1.0.0":', + " version: 1.0.0", + "", + '"pkg-b@npm:^2.0.0":', + " version: 2.0.0", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + lockfile.blocks = lockfile.blocks.filter((b) => { + const firstDescriptor = b.descriptors[0]; + return firstDescriptor === undefined || !firstDescriptor.includes("pkg-b"); + }); + + const output = roundTrip(lockfile); + expect(output).toContain("pkg-a"); + expect(output).not.toContain("pkg-b"); + }); + + it("allows enriching descriptors (adding a specifier)", () => { + const input = [ + "__metadata:", + " version: 8", + "", + '"@backstage/core@backstage:^":', + " version: 1.0.0", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + block.descriptors.push("@backstage/core@npm:1.0.0"); + + const output = roundTrip(lockfile); + expect(output).toContain('"@backstage/core@backstage:^, @backstage/core@npm:1.0.0":'); + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases: defensive branches +// --------------------------------------------------------------------------- + +describe("parser edge cases", () => { + it("stops parsing block body on an empty line", () => { + const input = ['"pkg@npm:1":', " version: 1.0.0", "", " linkType: hard", ""].join("\n"); + + const lockfile = parseLockfile(input); + expect(lockfile.blocks).toHaveLength(1); + const block = expectFirstBlock(lockfile); + expect(block.fields.version).toEqual({ + kind: "scalar", + raw: "1.0.0", + }); + expect(block.fields.linkType).toBeUndefined(); + }); + + it("stops parsing block body on a 2-space line that is not a valid field", () => { + const input = [ + '"pkg@npm:1":', + " version: 1.0.0", + " no-colon-here", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.version).toBeDefined(); + expect(block.fields.linkType).toBeUndefined(); + }); + + it("handles a section header with no sub-content", () => { + const input = [ + '"pkg@npm:1":', + " version: 1.0.0", + " dependencies:", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.dependencies).toBeUndefined(); + expect(block.fields.linkType).toEqual({ kind: "scalar", raw: "hard" }); + }); + + it("skips malformed 4-space map entries", () => { + const input = [ + '"pkg@npm:1":', + " version: 1.0.0", + " dependencies:", + " valid-dep: 1.0.0", + " no-colon-garbage", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.dependencies).toEqual({ + kind: "map", + entries: { "valid-dep": "1.0.0" }, + }); + }); + + it("skips malformed 6-space nested-map property lines", () => { + const input = [ + '"pkg@npm:1":', + " peerDependenciesMeta:", + " react:", + " optional: true", + " garbage-no-colon", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { react: { optional: "true" } }, + }); + }); + + it("breaks nested-map on a 4-space line that is not a key header", () => { + const input = [ + '"pkg@npm:1":', + " peerDependenciesMeta:", + " react:", + " optional: true", + " not-a-key-header 1.0.0", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { react: { optional: "true" } }, + }); + }); + + it("stops parsing on degenerate field lines with empty name", () => { + const input = [ + '"pkg@npm:1":', + " version: 1.0.0", + " : orphan-value", + " :", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.version).toBeDefined(); + expect(block.fields[""]).toBeUndefined(); + expect(block.fields.linkType).toBeUndefined(); + }); + + it("stops parsing on field lines with colon not followed by whitespace", () => { + const input = [ + '"pkg@npm:1":', + " version: 1.0.0", + " name:value-no-space", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.version).toBeDefined(); + expect(block.fields.name).toBeUndefined(); + expect(block.fields.linkType).toBeUndefined(); + }); + + it("handles map keys containing colons", () => { + const input = [ + '"pkg@npm:1":', + " dependencies:", + ' "key:with:colons": "npm:^1.0.0"', + " simple-key: value", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.dependencies).toEqual({ + kind: "map", + entries: { + '"key:with:colons"': '"npm:^1.0.0"', + "simple-key": "value", + }, + }); + }); + + it("handles nested-map keys containing colons", () => { + const input = [ + '"pkg@npm:1":', + " peerDependenciesMeta:", + " key:sub:", + " optional: true", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { "key:sub": { optional: "true" } }, + }); + }); + + it("handles nested-map property names containing colons", () => { + const input = [ + '"pkg@npm:1":', + " peerDependenciesMeta:", + " react:", + " prop:name: true", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { react: { "prop:name": "true" } }, + }); + }); + + it("drops orphan 4-space line that appears before any field header", () => { + const input = ['"pkg@npm:1":', " orphan-deep-line: value", " version: 1.0.0", ""].join( + "\n", + ); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.version).toEqual({ kind: "scalar", raw: "1.0.0" }); + }); + + it("stops block parsing on stray non-indented line before first field", () => { + const input = ['"pkg@npm:1":', "stray-line-at-top", " version: 1.0.0", ""].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.version).toBeUndefined(); + }); + + it("stops block parsing on stray non-indented line inside a map section", () => { + const input = [ + '"pkg@npm:1":', + " dependencies:", + " valid: 1.0.0", + "stray-line", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.dependencies).toEqual({ + kind: "map", + entries: { valid: "1.0.0" }, + }); + expect(block.fields.linkType).toBeUndefined(); + }); + + it("stops block parsing on stray non-indented line inside a nested-map section", () => { + const input = [ + '"pkg@npm:1":', + " peerDependenciesMeta:", + " react:", + " optional: true", + "stray-line", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { react: { optional: "true" } }, + }); + expect(block.fields.linkType).toBeUndefined(); + }); + + it("does not misidentify 5-space lines as nested-map headers", () => { + const input = [ + '"pkg@npm:1":', + " section:", + " five-space-key: value", + " normal-key: value", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + // 5-space line is NOT a nested-map header → section is parsed as flat map + expect(block.fields.section).toEqual({ + kind: "map", + entries: { " five-space-key": "value", "normal-key": "value" }, + }); + }); + + it("skips map entries with empty key (colon at indent boundary)", () => { + const input = [ + '"pkg@npm:1":', + " dependencies:", + " : empty-key-value", + " valid: 1.0.0", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.dependencies).toEqual({ + kind: "map", + entries: { valid: "1.0.0" }, + }); + }); + + it("skips nested-map key headers with empty key", () => { + const input = [ + '"pkg@npm:1":', + " peerDependenciesMeta:", + " :", + " optional: true", + " react:", + " optional: false", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { react: { optional: "false" } }, + }); + }); + + it("skips nested-map properties with empty prop name", () => { + const input = [ + '"pkg@npm:1":', + " peerDependenciesMeta:", + " react:", + " : empty-prop-value", + " optional: true", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: { react: { optional: "true" } }, + }); + }); + + it("produces empty map when all entries are malformed", () => { + const input = [ + '"pkg@npm:1":', + " dependencies:", + " no-colon-here", + " also-no-colon", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.dependencies).toEqual({ + kind: "map", + entries: {}, + }); + }); + + it("produces empty nested-map when all keys are malformed", () => { + const input = [ + '"pkg@npm:1":', + " peerDependenciesMeta:", + " not-a-key-header 1.0.0", + " orphan: true", + " linkType: hard", + "", + ].join("\n"); + + const lockfile = parseLockfile(input); + const block = expectFirstBlock(lockfile); + expect(block.fields.peerDependenciesMeta).toEqual({ + kind: "nested-map", + entries: {}, + }); + }); + + it("serializes lockfile with empty preamble", () => { + const lockfile: Lockfile = { + preamble: "", + blocks: [ + { + descriptors: ["pkg@npm:1"], + fields: { version: { kind: "scalar", raw: "1.0.0" } }, + }, + ], + }; + const output = serializeLockfile(lockfile); + expect(output).toBe('\n"pkg@npm:1":\n version: 1.0.0\n'); + }); +}); + +// --------------------------------------------------------------------------- +// Real-world lockfile round-trip +// --------------------------------------------------------------------------- + +describe("real-world fixture round-trip", () => { + it("round-trips the monorepo-with-backstage fixture", async () => { + const fs = await import("node:fs"); + const path = await import("node:path"); + const fixturePath = path.join( + import.meta.dirname, + "modules/generate-manifests/__fixtures__/monorepo-with-backstage/input/workspace/yarn.lock", + ); + const content = fs.readFileSync(fixturePath, "utf8"); + const lockfile = parseLockfile(content); + const output = serializeLockfile(lockfile); + expect(output).toBe(content); + }); +}); diff --git a/prepare-sources/lib/yarn-lock-parser.ts b/prepare-sources/lib/yarn-lock-parser.ts new file mode 100644 index 0000000..482d2ec --- /dev/null +++ b/prepare-sources/lib/yarn-lock-parser.ts @@ -0,0 +1,392 @@ +/** + * Zero-dependency Yarn Berry lockfile (SYML) parser and serializer. + * + * Designed for round-trip fidelity: parse → serialize produces output + * identical to the input for unmodified content. Field and entry ordering + * is preserved via JavaScript object insertion-order guarantees (ES2015+). + * + * Used by `generate-manifests` (read-only extraction), `protocol-resolution` + * (block replacement, descriptor enrichment, value rewriting), `package-cleanup` + * (block removal), and `validate` (structural comparison). + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Lockfile = { + preamble: string; + blocks: LockfileBlock[]; +}; + +export type LockfileBlock = { + descriptors: string[]; + fields: { [name: string]: LockfileField }; +}; + +export type LockfileField = ScalarField | MapField | NestedMapField; + +export type ScalarField = { kind: "scalar"; raw: string }; +export type MapField = { kind: "map"; entries: { [key: string]: string } }; +export type NestedMapField = { + kind: "nested-map"; + entries: { [key: string]: { [prop: string]: string } }; +}; + +// --------------------------------------------------------------------------- +// String-based matchers — no regex captures, no `string | undefined` +// +// Each function uses indexOf + substring + trimStart/trim to parse SYML +// field lines. All return types are fully narrowed (`string`, not +// `string | undefined`), so no v8-ignore guards are needed. +// --------------------------------------------------------------------------- + +/** + * Match a 2-space scalar field: ` name: value` + * Equivalent to `/^ ([^:]+):\s+(.+)$/` + * Name cannot contain colons → first colon is the separator. + */ +function matchScalar(line: string): { name: string; raw: string } | null { + const colonPos = line.indexOf(":", 2); + if (colonPos === -1) return null; + const name = line.substring(2, colonPos); + if (name.length === 0) return null; + const rest = line.substring(colonPos + 1); + const raw = rest.trimStart(); + if (raw.length === 0 || rest.length === raw.length) return null; + return { name, raw }; +} + +/** + * Match a 2-space section header: ` name:` (with optional trailing whitespace) + * Equivalent to `/^ ([^:]+):\s*$/` + * Name cannot contain colons → first colon is the separator. + */ +function matchSectionHeader(line: string): string | null { + const colonPos = line.indexOf(":", 2); + if (colonPos === -1) return null; + const name = line.substring(2, colonPos); + if (name.length === 0) return null; + const rest = line.substring(colonPos + 1); + if (rest.trim().length !== 0) return null; + return name; +} + +/** + * Match a 4-space map entry: ` key: value` + * Equivalent to `/^\s{4}(.+?):\s+(.+)$/` + * Key may contain colons (non-greedy) → try each colon left-to-right. + */ +function matchMapEntry(line: string): [key: string, value: string] | null { + let colonPos = line.indexOf(":", 4); + while (colonPos !== -1) { + const rest = line.substring(colonPos + 1); + const value = rest.trimStart(); + if (rest.length !== value.length && value.length > 0) { + const key = line.substring(4, colonPos); + if (key.length > 0) return [key, value]; + } + colonPos = line.indexOf(":", colonPos + 1); + } + return null; +} + +/** + * Match a 4-space nested-map key header: ` key:` (with optional trailing ws) + * Equivalent to `/^\s{4}(.+?):\s*$/` + * Key may contain colons → try each colon left-to-right. + */ +function matchNestedKey(line: string): string | null { + let colonPos = line.indexOf(":", 4); + while (colonPos !== -1) { + const rest = line.substring(colonPos + 1); + if (rest.trim().length === 0) { + const key = line.substring(4, colonPos); + if (key.length > 0) return key; + } + colonPos = line.indexOf(":", colonPos + 1); + } + return null; +} + +/** + * Match a 6-space nested-map property: ` prop: value` + * Equivalent to `/^\s{6}(.+?):\s+(.+)$/` + * Prop may contain colons → try each colon left-to-right. + */ +function matchNestedProp(line: string): [prop: string, value: string] | null { + let colonPos = line.indexOf(":", 6); + while (colonPos !== -1) { + const rest = line.substring(colonPos + 1); + const value = rest.trimStart(); + if (rest.length !== value.length && value.length > 0) { + const prop = line.substring(6, colonPos); + if (prop.length > 0) return [prop, value]; + } + colonPos = line.indexOf(":", colonPos + 1); + } + return null; +} + +/** + * Detect whether a 4-space line is a nested-map key header (e.g. ` react:`) + * rather than a flat map entry (e.g. ` react: "npm:^18"`). + * Equivalent to `/^\s{4}\S.*:\s*$/` — 4-space indent, non-space start, colon + * at end with optional trailing whitespace. + */ +function isNestedMapHeader(line: string): boolean { + if (!line.startsWith(" ") || line.charAt(4) === " ") return false; + const colonPos = line.indexOf(":", 4); + if (colonPos === -1) return false; + return line.substring(colonPos + 1).trim().length === 0; +} + +// --------------------------------------------------------------------------- +// Structured grouping types — typed fields instead of raw string[] +// --------------------------------------------------------------------------- + +type BlockGroup = { descriptor: string; body: string[] }; +type FieldGroup = { header: string; subLines: string[] }; + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +export function parseLockfile(content: string): Lockfile { + const lines = content.split("\n"); + + const preambleEnd = findPreambleEnd(lines); + const preamble = lines.slice(0, preambleEnd).join("\n"); + + const blockLines = lines.slice(preambleEnd); + const blocks = splitIntoBlockGroups(blockLines).map(parseBlockGroup); + + return { preamble, blocks }; +} + +/** + * Find where the preamble ends (comment lines, blank lines, __metadata block). + * Returns the index of the first non-preamble line. + */ +function findPreambleEnd(lines: string[]): number { + let i = 0; + for (const line of lines) { + if (line.startsWith("#") || line === "") { + i++; + } else if (line.startsWith("__metadata:")) { + i++; // the __metadata: line itself + for (const metaLine of lines.slice(i)) { + if (!metaLine.startsWith(" ")) break; + i++; + } + break; + } else { + break; + } + } + return i; +} + +/** + * Split lines into block groups, where each group starts with a descriptor + * line (starts at column 0, ends with ":") and includes all following + * indented (2+-space) body lines. Blank lines and 0-space non-descriptor + * lines stop body collection — matching the original parser's strict + * block-termination behavior. + */ +function splitIntoBlockGroups(lines: string[]): BlockGroup[] { + const groups: BlockGroup[] = []; + let current: BlockGroup | null = null; + for (const line of lines) { + if (!line.startsWith(" ") && !line.startsWith("#") && line.endsWith(":")) { + current = { descriptor: line, body: [] }; + groups.push(current); + } else if (current !== null && line.startsWith(" ")) { + current.body.push(line); + } else { + current = null; + } + } + return groups; +} + +function parseBlockGroup(group: BlockGroup): LockfileBlock { + const descriptors = parseDescriptors(group.descriptor.slice(0, -1)); + const fields = parseFields(group.body); + return { descriptors, fields }; +} + +function parseDescriptors(raw: string): string[] { + const trimmed = raw.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).split(", "); + } + return [trimmed]; +} + +/** + * Group body lines by top-level field (2-space indent), then parse each + * group into a LockfileField. + */ +function parseFields(bodyLines: string[]): { [name: string]: LockfileField } { + const fields: { [name: string]: LockfileField } = {}; + + for (const { header, subLines } of groupByField(bodyLines)) { + const scalar = matchScalar(header); + if (scalar) { + fields[scalar.name] = { kind: "scalar", raw: scalar.raw }; + continue; + } + + const sectionName = matchSectionHeader(header); + if (sectionName && subLines.length > 0) { + const isNested = subLines.some((l) => l.startsWith(" ") || isNestedMapHeader(l)); + if (isNested) { + fields[sectionName] = parseNestedMapLines(subLines); + } else { + fields[sectionName] = parseMapLines(subLines); + } + continue; + } + if (sectionName) continue; // valid section header with no sub-content + break; // unrecognized line — stop parsing (matches original behavior) + } + + return fields; +} + +/** + * Group body lines so that each 2-space line starts a new group, and + * deeper-indented lines (4+) are appended to the current group. + * Returns structured objects with typed fields. + */ +function groupByField(lines: string[]): FieldGroup[] { + const groups: FieldGroup[] = []; + let current: FieldGroup | null = null; + for (const line of lines) { + if (line.startsWith(" ") && !line.startsWith(" ")) { + current = { header: line, subLines: [] }; + groups.push(current); + } else if (current !== null) { + current.subLines.push(line); + } + } + return groups; +} + +function parseMapLines(lines: string[]): MapField { + const entries: { [key: string]: string } = {}; + for (const line of lines) { + const kv = matchMapEntry(line); + if (kv) { + entries[kv[0]] = kv[1]; + } + } + return { kind: "map", entries }; +} + +function parseNestedMapLines(lines: string[]): NestedMapField { + const entries: { [key: string]: { [prop: string]: string } } = {}; + let currentEntry: { [prop: string]: string } | null = null; + + for (const line of lines) { + if (line.startsWith(" ")) { + if (currentEntry) { + const pv = matchNestedProp(line); + if (pv) { + currentEntry[pv[0]] = pv[1]; + } + } + continue; + } + + const key = matchNestedKey(line); + if (key) { + currentEntry = {}; + entries[key] = currentEntry; + } + } + + return { kind: "nested-map", entries }; +} + +// --------------------------------------------------------------------------- +// Serializer +// --------------------------------------------------------------------------- + +export function serializeLockfile(lockfile: Lockfile): string { + const parts: string[] = []; + + if (lockfile.preamble) { + parts.push(lockfile.preamble); + } + + for (const block of lockfile.blocks) { + parts.push("", serializeBlock(block)); + } + + return parts.join("\n") + "\n"; +} + +export function serializeBlock(block: LockfileBlock): string { + const descriptorLine = `"${block.descriptors.join(", ")}":`; + const lines = [descriptorLine]; + + for (const [name, field] of Object.entries(block.fields)) { + switch (field.kind) { + case "scalar": + lines.push(` ${name}: ${field.raw}`); + break; + case "map": + lines.push(` ${name}:`); + for (const [key, value] of Object.entries(field.entries)) { + lines.push(` ${key}: ${value}`); + } + break; + case "nested-map": + lines.push(` ${name}:`); + for (const [key, props] of Object.entries(field.entries)) { + lines.push(` ${key}:`); + for (const [prop, value] of Object.entries(props)) { + lines.push(` ${prop}: ${value}`); + } + } + break; + /* v8 ignore next 4 -- exhaustive switch; compile-time guarantee */ + default: { + const exhaustiveCheck: never = field; + return exhaustiveCheck; + } + } + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Convenience accessors +// --------------------------------------------------------------------------- + +export function getScalar(block: LockfileBlock, fieldName: string): string | undefined { + const field = block.fields[fieldName]; + if (field?.kind !== "scalar") return undefined; + return field.raw.replace(/^"|"$/g, ""); +} + +export function getMap( + block: LockfileBlock, + fieldName: string, +): { [key: string]: string } | undefined { + const field = block.fields[fieldName]; + if (field?.kind !== "map") return undefined; + return field.entries; +} + +export function getNestedMap( + block: LockfileBlock, + fieldName: string, +): { [key: string]: { [prop: string]: string } } | undefined { + const field = block.fields[fieldName]; + if (field?.kind !== "nested-map") return undefined; + return field.entries; +} From 76526d735ea8a8fe042ce0e7f511026fcc59469b Mon Sep 17 00:00:00 2001 From: David Festal Date: Thu, 10 Sep 2026 21:51:59 +0200 Subject: [PATCH 2/3] feat(prepare-sources): implement `plugin-removal` pipeline module Intersect `plugins-list.yaml` with `rhdh-supported-packages.txt` for the current workspace, preserve packages referenced by `dist-dynamic/embedded/*/package.json`, remove infrastructure directories (`packages/app`, `packages/backend`, `.storybook`, `examples/`, `node_modules`), and delete remaining non-supported plugin directories. Rewrite the filtered `plugins-list.yaml` back to the overlay path, preserving full lines including export CLI args. Expose `overlayRepoRoot` on `PipelineInputs` so tier-list files can be read from the overlay repository root instead of resolving `../..` inside the module. Update the fixture loader to support an `input/overlay-root/` directory for tier-list fixtures. Add the module implementation, unit tests, fixtures, and README.md; wire the module into the pipeline runner. Assisted-by: Cursor Signed-off-by: David Festal Co-authored-by: Cursor Signed-off-by: David Festal --- prepare-sources/lib/cli.test.ts | 1 + prepare-sources/lib/modules.test.ts | 1 + prepare-sources/lib/modules.ts | 3 +- .../lib/modules/plugin-removal/DESIGN.md | 164 +++++++++++++ .../lib/modules/plugin-removal/README.md | 98 ++++++++ .../overlay-root/rhdh-supported-packages.txt | 1 + .../input/overlay/plugins-list.yaml | 1 + .../input/overlay/source.json | 6 + .../input/workspace/package.json | 5 + .../plugins/another-lib-source/package.json | 4 + .../plugins/embedded-lib-source/package.json | 4 + .../embedded/another-lib/package.json | 4 + .../embedded/embedded-lib/package.json | 4 + .../plugins/exported-plugin/package.json | 4 + .../plugins/unrelated-pkg/package.json | 4 + .../output/overlay/plugins-list.yaml | 1 + .../output/overlay/source.json | 6 + .../output/workspace/package.json | 5 + .../plugins/another-lib-source/package.json | 4 + .../plugins/embedded-lib-source/package.json | 4 + .../embedded/another-lib/package.json | 4 + .../embedded/embedded-lib/package.json | 4 + .../plugins/exported-plugin/package.json | 4 + .../overlay-root/rhdh-supported-packages.txt | 4 + .../input/overlay/plugins-list.yaml | 8 + .../input/overlay/source.json | 6 + .../workspace/examples/demo/package.json | 4 + .../input/workspace/package.json | 9 + .../workspace/packages/app-next/package.json | 4 + .../input/workspace/packages/app/package.json | 4 + .../workspace/packages/backend/package.json | 4 + .../packages/gitlab-backend/package.json | 4 + .../workspace/packages/gitlab/package.json | 4 + .../plugins/keep-plugin/.storybook/main.ts | 1 + .../keep-plugin/dist-dynamic/package.json | 5 + .../plugins/keep-plugin/package.json | 4 + .../plugins/keep-with-args/package.json | 4 + .../plugins/remove-plugin/package.json | 4 + .../output/overlay/plugins-list.yaml | 6 + .../output/overlay/source.json | 6 + .../output/workspace/package.json | 9 + .../workspace/packages/gitlab/package.json | 4 + .../keep-plugin/dist-dynamic/package.json | 5 + .../plugins/keep-plugin/package.json | 4 + .../plugins/keep-with-args/package.json | 4 + .../overlay-root/rhdh-supported-packages.txt | 2 + .../input/overlay/plugins-list.yaml | 2 + .../input/overlay/source.json | 6 + .../input/workspace/package.json | 5 + .../workspace/plugins/alpha/package.json | 4 + .../input/workspace/plugins/beta/package.json | 4 + .../output/overlay/plugins-list.yaml | 0 .../output/overlay/source.json | 6 + .../output/workspace/package.json | 5 + .../error | 1 + .../overlay-root/rhdh-supported-packages.txt | 1 + .../input/overlay/source.json | 6 + .../input/workspace/package.json | 4 + .../error | 1 + .../input/overlay/plugins-list.yaml | 1 + .../input/overlay/source.json | 6 + .../input/workspace/package.json | 4 + .../lib/modules/plugin-removal/index.test.ts | 159 +++++++++++++ .../lib/modules/plugin-removal/index.ts | 216 ++++++++++++++++++ prepare-sources/lib/pipeline.test.ts | 4 +- prepare-sources/lib/pipeline.ts | 5 +- prepare-sources/lib/test-utils.ts | 40 +++- 67 files changed, 917 insertions(+), 8 deletions(-) create mode 100644 prepare-sources/lib/modules/plugin-removal/DESIGN.md create mode 100644 prepare-sources/lib/modules/plugin-removal/README.md create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay-root/rhdh-supported-packages.txt create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay/plugins-list.yaml create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/another-lib-source/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/embedded-lib-source/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/dist-dynamic/embedded/another-lib/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/dist-dynamic/embedded/embedded-lib/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/unrelated-pkg/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/overlay/plugins-list.yaml create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/overlay/source.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/another-lib-source/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/embedded-lib-source/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/dist-dynamic/embedded/another-lib/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/dist-dynamic/embedded/embedded-lib/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay-root/rhdh-supported-packages.txt create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay/plugins-list.yaml create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/examples/demo/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/app-next/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/app/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/backend/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/gitlab-backend/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/gitlab/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/.storybook/main.ts create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/dist-dynamic/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-with-args/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/remove-plugin/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/overlay/plugins-list.yaml create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/overlay/source.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/packages/gitlab/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-plugin/dist-dynamic/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-plugin/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-with-args/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay-root/rhdh-supported-packages.txt create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay/plugins-list.yaml create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/plugins/alpha/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/plugins/beta/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/overlay/plugins-list.yaml create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/overlay/source.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/workspace/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/error create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/overlay-root/rhdh-supported-packages.txt create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/error create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/overlay/plugins-list.yaml create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/workspace/package.json create mode 100644 prepare-sources/lib/modules/plugin-removal/index.test.ts create mode 100644 prepare-sources/lib/modules/plugin-removal/index.ts diff --git a/prepare-sources/lib/cli.test.ts b/prepare-sources/lib/cli.test.ts index e0ed473..c61453f 100644 --- a/prepare-sources/lib/cli.test.ts +++ b/prepare-sources/lib/cli.test.ts @@ -90,6 +90,7 @@ describe("main", () => { const inputs = { workspacePath: "/ws", overlayPath: "/ov", + overlayRepoRoot: "/", source: { repo: "https://github.com/example/repo", "repo-ref": "main", diff --git a/prepare-sources/lib/modules.test.ts b/prepare-sources/lib/modules.test.ts index b18c254..6cf2c9d 100644 --- a/prepare-sources/lib/modules.test.ts +++ b/prepare-sources/lib/modules.test.ts @@ -29,6 +29,7 @@ describe("MODULES", () => { await stub.run({ workspacePath: "/tmp/ws", overlayPath: "/tmp/overlay", + overlayRepoRoot: "/tmp", source: { repo: "https://github.com/example/repo", "repo-ref": "main", diff --git a/prepare-sources/lib/modules.ts b/prepare-sources/lib/modules.ts index d3e627a..bfd37a1 100644 --- a/prepare-sources/lib/modules.ts +++ b/prepare-sources/lib/modules.ts @@ -2,6 +2,7 @@ import type { ModuleContext, PipelineModule } from "./pipeline.ts"; import { run as hermeticPrep } from "./modules/hermetic-prep/index.ts"; import { run as makeSelfContained } from "./modules/make-self-contained/index.ts"; import { run as generateManifests } from "./modules/generate-manifests/index.ts"; +import { run as pluginRemoval } from "./modules/plugin-removal/index.ts"; async function notImplemented(ctx: ModuleContext): Promise { ctx.log("not yet implemented"); @@ -12,7 +13,7 @@ export const MODULES: readonly PipelineModule[] = [ { name: "seed-frontend-lockfiles", run: notImplemented }, { name: "make-self-contained", run: makeSelfContained }, { name: "generate-manifests", run: generateManifests }, - { name: "plugin-removal", run: notImplemented }, + { name: "plugin-removal", run: pluginRemoval }, { name: "file-cleanup", run: notImplemented }, { name: "protocol-resolution", run: notImplemented }, { name: "package-cleanup", run: notImplemented }, diff --git a/prepare-sources/lib/modules/plugin-removal/DESIGN.md b/prepare-sources/lib/modules/plugin-removal/DESIGN.md new file mode 100644 index 0000000..1e04f19 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/DESIGN.md @@ -0,0 +1,164 @@ +# plugin-removal — design (grill-with-docs) + +Pre-implementation design captured from the [RHIDP-15835](https://redhat.atlassian.net/browse/RHIDP-15835) grilling session. **Implement against this document**; keep `README.md` as the post-implementation module guide. + +Related: [source-preparation-design-decisions.md](../../../docs/source-preparation-design-decisions.md) §5, §11. + +--- + +## Language + +**Supported plugin:** +A plugin whose `workspace/plugin-path` entry appears in `rhdh-supported-packages.txt`. +_Avoid_: GA plugin, production plugin (support tier is expressed elsewhere). + +**Surviving plugin:** +A plugin directory kept on disk after scrubbing — either listed in the filtered `plugins-list.yaml` or preserved as an embedded package. +_Avoid_: exported plugin (export happens again later in `re-export`). + +**Embedded package:** +A workspace package whose npm `name` appears under `*/dist-dynamic/embedded/*/package.json` from the initial export. Preserved even when not listed in `plugins-list.yaml`, so re-export does not fall back to npm and drift versions. +_Avoid_: bundled dependency (too vague). + +**Infrastructure package:** +App/backend shell directories removed before plugin scanning — not plugin exports. Examples: `packages/app`, `packages/backend`, `packages/app-next`, `.storybook`, top-level `examples/`, `node_modules`. +_Avoid_: scaffold (ambiguous). + +## Relationships + +- A **Supported plugin** is always a **Surviving plugin** +- An **Embedded package** may be a **Surviving plugin** without being a **Supported plugin** +- **Infrastructure packages** are never **Surviving plugins** +- Filtered `plugins-list.yaml` lists only **Supported plugins** (full lines preserved, including CLI args) + +## Example dialogue + +> **Dev:** "Why is `plugins/orchestrator-common` still on disk after scrubbing even though it's not in `plugins-list.yaml`?" +> +> **Domain expert:** "Because a surviving exported plugin embedded it — check `dist-dynamic/embedded/`. We preserve **Embedded packages** by npm `name`, not by plugins-list path." +> +> **Dev:** "Should we delete community plugin metadata from the overlay?" +> +> **Domain expert:** "No — that's catalog assembly, not **plugin-removal**. This module only scrubs **plugin source** in the workspace checkout." +> +> **Dev:** "Does `rhdh-community-packages.txt` filter metadata YAML inside a workspace?" +> +> **Domain expert:** "No. Per-file metadata filtering uses `spec.support` in `remove-pre-GA-entities.js`, driven by `rhdh-supported-packages.txt` and `default.packages.yaml` — not the community list. Community tier is used elsewhere (compatibility checks, wiki badges, mandatory PR labeling)." + +## Flagged ambiguities + +- RHIDP-15835 mentions "community-tier plugins (filtered by the support-tier files)" — for **source scrubbing**, community plugins are excluded because they are **not** on the supported whitelist. `rhdh-community-packages.txt` is **not** an input to this module. +- Test/dev cleanup (`*.test.ts`, `__tests__/`, `dev/`, `e2e-tests/`) lives in `file-cleanup` per design doc §11, even though `sync-midstream.sh` runs it in the same loop (lines 917–924). +- Catalog / metadata preservation is out of scope — overlay `metadata/*.yaml` and `catalog-entities/` are untouched. + +--- + +## Resolved decisions + +### 1. Whitelist from `rhdh-supported-packages.txt` only + +Keep plugins whose `workspace/plugin-path` is in `rhdh-supported-packages.txt`. Do not read `rhdh-community-packages.txt` for source scrubbing. + +`sync-midstream.sh` also has a separate community blacklist pass (lines 1066–1094) at repo scope; in the per-workspace pipeline the whitelist intersection is sufficient. + +### 2. `overlayRepoRoot` on `PipelineInputs` + +Tier list files live at the overlay **repository** root (`rhdh-supported-packages.txt`), while `ctx.overlayPath` is `overlay-repo/workspaces//`. + +Expose `ctx.overlayRepoRoot` on `PipelineInputs`, derived in `loadPipelineInputs` as `path.resolve(overlayPath, '../..')`. Do **not** traverse with `../..` inside the module (security scanner / linter concerns). + +### 3. Pipeline starts after initial export + +The CLI runs after `export-dynamic.yaml` has completed an initial `rhdh-cli plugin export`. At `plugin-removal` time, `dist-dynamic/` and `dist-dynamic/embedded/` already exist. + +Embedded preservation mirrors `sync-midstream.sh` lines 857–870: scan **all** `dist-dynamic/embedded/*/package.json` under the workspace **before** deleting package directories. + +### 4. Single pass (not sparse-checkout stages) + +`sync-midstream.sh` filtered `plugins-list.yaml` early (lines 691–714) to drive sparse checkout, then scrubbed directories later (886–915). In the overlay workflow the workspace is fully on disk — filter `plugins-list.yaml` and remove directories in **one** module pass. + +### 5. Preserve full `plugins-list.yaml` lines + +Surviving entries are written back with their complete text, including trailing `:` and export CLI arguments (`--embed-package`, `--suppress-native-package`, etc.). + +### 6. Flat-repo path normalization + +Supported-packages entries may use a trailing `/.` for flat repos (e.g. `pagerduty/.`). Normalize by stripping `/.` on both tier-list entries and constructed paths before comparison. No current entries use this, but the bash handles it defensively. + +### 7. Package directory matching + +A directory is kept when its path relative to the workspace: + +- equals a surviving plugins-list path, or +- equals `path/dist-dynamic`, or +- its `package.json` `name` matches an embedded package name + +Scan `package.json` at mindepth 2 (exclude workspace root `package.json`), same as `sync-midstream.sh` line 916. + +### 8. Scope split vs `file-cleanup` + +| Concern | Module | +| ---------------------------------------------------------------------------- | ---------------- | +| Infrastructure dirs, non-supported plugin dirs, filtered `plugins-list.yaml` | `plugin-removal` | +| Test/dev files within surviving packages | `file-cleanup` | + +Infrastructure patterns from bash (lines 878–884): `examples/` (depth 1), `packages/app`, `packages/backend`, `packages/app-next`, `.storybook`, `node_modules`. + +### 9. Out of scope + +- Metadata YAML filtering (`remove-pre-GA-entities.js`) +- `rhdh-community-packages.txt` as scrub input +- `protocol-resolution` / `package-cleanup` (lockfile and package.json rewriting) + +--- + +## Algorithm (implementation contract) + +1. Read `plugins-list.yaml` from `ctx.overlayPath` +2. Read `rhdh-supported-packages.txt` from `ctx.overlayRepoRoot` +3. Workspace name = `path.basename(ctx.overlayPath)` +4. Surviving list = plugins-list entries whose `workspaceName/pluginPath` is in the supported set +5. Collect embedded npm names from all `**/dist-dynamic/embedded/*/package.json` +6. Remove infrastructure directories +7. For each `package.json` at depth ≥ 2: remove parent dir unless kept by list, `dist-dynamic` path, or embedded name +8. Write filtered `plugins-list.yaml` + +## Error handling + +| Condition | Behavior | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `rhdh-supported-packages.txt` missing at overlay repo root | Throw | +| `plugins-list.yaml` missing in overlay path | Throw | +| No surviving plugins-list entries | Write empty `plugins-list.yaml`; remove all scannable plugin dirs except embedded | + +## sync-midstream.sh references + +| Behavior | Lines | +| ------------------------------------------------------------ | --------- | +| plugins-list ∩ supported (sparse checkout era) | 691–714 | +| Embedded package collection | 857–870 | +| Infrastructure removal | 878–884 | +| Non-exported plugin removal | 886–915 | +| Community path deletion (repo-wide, **not** replicated here) | 1066–1094 | +| Test/dev removal (**file-cleanup**, not here) | 917–924 | + +## Test fixtures + +Use realistic workspace layouts — see +[source-preparation-design-decisions.md](../../../docs/source-preparation-design-decisions.md) +§12 (fixture workspace layout). + +**For this module specifically:** + +- Exportable plugins under `plugins//` with `plugins/:` in + `plugins-list.yaml` (or `packages/:` when mirroring gitlab-style flat + repos). +- Infrastructure under `packages/app`, `packages/backend`, etc. — not mixed into + `plugins/`. +- Embedded source as a sibling plugin package (e.g. `plugins/embedded-lib/`) whose + `package.json` `name` matches `dist-dynamic/embedded/*/package.json`. +- Post-export state: `plugins//dist-dynamic/` including + `embedded/` when testing preservation. +- Tier list in `input/overlay-root/rhdh-supported-packages.txt` with lines like + `/plugins/` (fixture name = workspace name via + `loadFixture`). diff --git a/prepare-sources/lib/modules/plugin-removal/README.md b/prepare-sources/lib/modules/plugin-removal/README.md new file mode 100644 index 0000000..6540a8e --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/README.md @@ -0,0 +1,98 @@ +# plugin-removal + +Removes plugin source from the workspace that is not needed for building +supported dynamic plugins, and rewrites `plugins-list.yaml` to list only the +surviving supported entries. + +**Pipeline position:** after `generate-manifests`, before `file-cleanup` — see +`modules.ts` for the current ordering. + +## Problem + +After the overlay `export-dynamic` workflow runs, the workspace checkout still +contains the full upstream tree: infrastructure shells (`packages/app`, +`packages/backend`), plugins not exported from this workspace, and plugins not +in the Red Hat supported tier. + +Downstream Konflux builds only need supported plugin source plus any embedded +packages referenced from the initial export's `dist-dynamic/embedded/` output. + +`generate-manifests` must run **before** this module so versions of packages +about to be scrubbed are still on disk for `protocol-resolution`. + +## What the module does + +1. Intersect `plugins-list.yaml` with `rhdh-supported-packages.txt` for this + workspace (`path.basename(ctx.overlayPath)`) +2. Collect embedded package names from all `dist-dynamic/embedded/*/package.json` + files under the workspace +3. Remove infrastructure directories (`examples/`, `packages/app`, + `packages/backend`, `packages/app-next`, `.storybook`, `node_modules`) +4. Remove plugin directories not in the surviving list (unless preserved as an + embedded package, matched by npm `name`) +5. Write filtered `plugins-list.yaml` back to the overlay path (full lines + preserved, including export CLI args) + +Test and dev file removal is **not** done here — see `file-cleanup`. + +## Inputs and outputs + +| Input | Location | +| ------------------- | ------------------------------------------------- | +| Workspace tree | `ctx.workspacePath` | +| Plugins list | `ctx.overlayPath/plugins-list.yaml` | +| Supported tier list | `ctx.overlayRepoRoot/rhdh-supported-packages.txt` | + +| Output | Location | +| --------------------- | ----------------------------------- | +| Scrubbed workspace | `ctx.workspacePath` (in place) | +| Filtered plugins list | `ctx.overlayPath/plugins-list.yaml` | + +## Non-obvious behavior + +**Whitelist, not community blacklist.** Only `rhdh-supported-packages.txt` is +read. Community plugins are removed because they are absent from that list. +`rhdh-community-packages.txt` is used elsewhere (compatibility checks, wiki, +PR labeling) — not here. + +**Embedded packages.** The pipeline starts after the initial export, so +`dist-dynamic/embedded/` already exists. Packages referenced there are kept +even when not in `plugins-list.yaml`, matching `sync-midstream.sh` embedded +preservation logic. + +**`overlayRepoRoot`.** Tier list files live at the overlay repo root, not under +`workspaces//`. The pipeline exposes `ctx.overlayRepoRoot` explicitly +rather than resolving `../..` inside the module. + +**Metadata untouched.** `workspaces/*/metadata/*.yaml` and catalog entities are +not modified. Catalog filtering is a separate concern (`remove-pre-GA-entities.js`). + +## Differences from `sync-midstream.sh` + +| Aspect | `sync-midstream.sh` | This module | +| ----------------- | ---------------------------------------------- | -------------------------------- | +| Checkout | Sparse; early plugins-list filter drives fetch | Full workspace already on disk | +| Pass structure | Filter list, then scrub dirs (separate steps) | Single pass | +| Community removal | Repo-wide blacklist pass | Implicit via supported whitelist | +| Test file removal | Same loop as scrub | `file-cleanup` module | + +Source references: embedded collection and plugin scrubbing in +`sync-midstream.sh` lines 857–915; infrastructure removal lines 878–884. + +## Error conditions + +| Condition | Behavior | +| ---------------------------------------------------------- | ----------------------------------------------------------------------- | +| `rhdh-supported-packages.txt` missing at overlay repo root | Throw | +| `plugins-list.yaml` missing in overlay path | Throw | +| No surviving plugins-list entries | Empty `plugins-list.yaml`; remove scannable plugin dirs except embedded | + +## Tests + +Fixture-based I/O tests under `__fixtures__/`. Each subdirectory name is the +Vitest case title; see the fixture catalog comment in `index.test.ts`. + +Typical workspace layout: exportable plugins under `plugins/`, infrastructure +shells and shared libs under `packages/`. Tier-list files go under +`input/overlay-root/` when needed (fixture directory name = workspace name for +tier-list entries). See `test-utils.ts` for the full fixture convention. diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay-root/rhdh-supported-packages.txt b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay-root/rhdh-supported-packages.txt new file mode 100644 index 0000000..bcbda11 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay-root/rhdh-supported-packages.txt @@ -0,0 +1 @@ +preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/plugins/exported-plugin diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay/plugins-list.yaml b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay/plugins-list.yaml new file mode 100644 index 0000000..7052911 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay/plugins-list.yaml @@ -0,0 +1 @@ +plugins/exported-plugin: --embed-package @example/embedded-lib diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay/source.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/package.json new file mode 100644 index 0000000..00da58d --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/workspace", + "version": "1.0.0", + "private": true +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/another-lib-source/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/another-lib-source/package.json new file mode 100644 index 0000000..10300fa --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/another-lib-source/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/another-lib", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/embedded-lib-source/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/embedded-lib-source/package.json new file mode 100644 index 0000000..4357afa --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/embedded-lib-source/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/embedded-lib", + "version": "2.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/dist-dynamic/embedded/another-lib/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/dist-dynamic/embedded/another-lib/package.json new file mode 100644 index 0000000..10300fa --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/dist-dynamic/embedded/another-lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/another-lib", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/dist-dynamic/embedded/embedded-lib/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/dist-dynamic/embedded/embedded-lib/package.json new file mode 100644 index 0000000..4357afa --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/dist-dynamic/embedded/embedded-lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/embedded-lib", + "version": "2.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/package.json new file mode 100644 index 0000000..6824251 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/exported-plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/exported-plugin", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/unrelated-pkg/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/unrelated-pkg/package.json new file mode 100644 index 0000000..6adea9d --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/input/workspace/plugins/unrelated-pkg/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/unrelated-pkg", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/overlay/plugins-list.yaml b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/overlay/plugins-list.yaml new file mode 100644 index 0000000..7052911 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/overlay/plugins-list.yaml @@ -0,0 +1 @@ +plugins/exported-plugin: --embed-package @example/embedded-lib diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/overlay/source.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/package.json new file mode 100644 index 0000000..00da58d --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/workspace", + "version": "1.0.0", + "private": true +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/another-lib-source/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/another-lib-source/package.json new file mode 100644 index 0000000..10300fa --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/another-lib-source/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/another-lib", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/embedded-lib-source/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/embedded-lib-source/package.json new file mode 100644 index 0000000..4357afa --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/embedded-lib-source/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/embedded-lib", + "version": "2.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/dist-dynamic/embedded/another-lib/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/dist-dynamic/embedded/another-lib/package.json new file mode 100644 index 0000000..10300fa --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/dist-dynamic/embedded/another-lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/another-lib", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/dist-dynamic/embedded/embedded-lib/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/dist-dynamic/embedded/embedded-lib/package.json new file mode 100644 index 0000000..4357afa --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/dist-dynamic/embedded/embedded-lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/embedded-lib", + "version": "2.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/package.json new file mode 100644 index 0000000..6824251 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin/output/workspace/plugins/exported-plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/exported-plugin", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay-root/rhdh-supported-packages.txt b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay-root/rhdh-supported-packages.txt new file mode 100644 index 0000000..bed37d5 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay-root/rhdh-supported-packages.txt @@ -0,0 +1,4 @@ +# supported plugins for this fixture workspace +preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/plugins/keep-plugin +preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/plugins/keep-with-args +preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/packages/gitlab diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay/plugins-list.yaml b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay/plugins-list.yaml new file mode 100644 index 0000000..8f08e7b --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay/plugins-list.yaml @@ -0,0 +1,8 @@ +#plugins/remove-plugin: not in supported tier +plugins/keep-plugin: +plugins/remove-plugin: +plugins/keep-with-args: --embed-package @example/pkg + +# packages/ prefix paths (gitlab-style flat repo layout) +packages/gitlab: +packages/gitlab-backend: diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay/source.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay/source.json new file mode 100644 index 0000000..fd63efc --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/monorepo", + "repo-ref": "abc123", + "repo-flat": true, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/examples/demo/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/examples/demo/package.json new file mode 100644 index 0000000..4925b07 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/examples/demo/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/demo", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/package.json new file mode 100644 index 0000000..73f89e4 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/package.json @@ -0,0 +1,9 @@ +{ + "name": "@example/workspace", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*", + "packages/*" + ] +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/app-next/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/app-next/package.json new file mode 100644 index 0000000..805a734 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/app-next/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/app-next", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/app/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/app/package.json new file mode 100644 index 0000000..1eec1e5 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/app", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/backend/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/backend/package.json new file mode 100644 index 0000000..93bdfc4 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/backend/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/backend", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/gitlab-backend/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/gitlab-backend/package.json new file mode 100644 index 0000000..d615455 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/gitlab-backend/package.json @@ -0,0 +1,4 @@ +{ + "name": "@immobiliare/backstage-plugin-gitlab-backend", + "version": "6.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/gitlab/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/gitlab/package.json new file mode 100644 index 0000000..3104fa2 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/packages/gitlab/package.json @@ -0,0 +1,4 @@ +{ + "name": "@immobiliare/backstage-plugin-gitlab", + "version": "6.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/.storybook/main.ts b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/.storybook/main.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/.storybook/main.ts @@ -0,0 +1 @@ +export default {}; diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/dist-dynamic/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/dist-dynamic/package.json new file mode 100644 index 0000000..7a4b5db --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/dist-dynamic/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/keep-plugin", + "version": "1.0.0", + "main": "dist/index.cjs.js" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/package.json new file mode 100644 index 0000000..ff0740c --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/keep-plugin", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-with-args/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-with-args/package.json new file mode 100644 index 0000000..bf98740 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/keep-with-args/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/keep-with-args", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/remove-plugin/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/remove-plugin/package.json new file mode 100644 index 0000000..532fd4a --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/input/workspace/plugins/remove-plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/remove-plugin", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/overlay/plugins-list.yaml b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/overlay/plugins-list.yaml new file mode 100644 index 0000000..072e4c0 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/overlay/plugins-list.yaml @@ -0,0 +1,6 @@ +#plugins/remove-plugin: not in supported tier +plugins/keep-plugin: +plugins/keep-with-args: --embed-package @example/pkg + +# packages/ prefix paths (gitlab-style flat repo layout) +packages/gitlab: diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/overlay/source.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/overlay/source.json new file mode 100644 index 0000000..fd63efc --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/monorepo", + "repo-ref": "abc123", + "repo-flat": true, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/package.json new file mode 100644 index 0000000..73f89e4 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/package.json @@ -0,0 +1,9 @@ +{ + "name": "@example/workspace", + "version": "1.0.0", + "private": true, + "workspaces": [ + "plugins/*", + "packages/*" + ] +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/packages/gitlab/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/packages/gitlab/package.json new file mode 100644 index 0000000..3104fa2 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/packages/gitlab/package.json @@ -0,0 +1,4 @@ +{ + "name": "@immobiliare/backstage-plugin-gitlab", + "version": "6.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-plugin/dist-dynamic/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-plugin/dist-dynamic/package.json new file mode 100644 index 0000000..7a4b5db --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-plugin/dist-dynamic/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/keep-plugin", + "version": "1.0.0", + "main": "dist/index.cjs.js" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-plugin/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-plugin/package.json new file mode 100644 index 0000000..ff0740c --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/keep-plugin", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-with-args/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-with-args/package.json new file mode 100644 index 0000000..bf98740 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list/output/workspace/plugins/keep-with-args/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/keep-with-args", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay-root/rhdh-supported-packages.txt b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay-root/rhdh-supported-packages.txt new file mode 100644 index 0000000..60c6738 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay-root/rhdh-supported-packages.txt @@ -0,0 +1,2 @@ +# no entries match this workspace +other-workspace/plugins/something diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay/plugins-list.yaml b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay/plugins-list.yaml new file mode 100644 index 0000000..11fb04d --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay/plugins-list.yaml @@ -0,0 +1,2 @@ +plugins/alpha: +plugins/beta: --embed-package @scope/lib diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay/source.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/package.json new file mode 100644 index 0000000..00da58d --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/workspace", + "version": "1.0.0", + "private": true +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/plugins/alpha/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/plugins/alpha/package.json new file mode 100644 index 0000000..72d2fbe --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/plugins/alpha/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/alpha", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/plugins/beta/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/plugins/beta/package.json new file mode 100644 index 0000000..162a4a1 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/input/workspace/plugins/beta/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/beta", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/overlay/plugins-list.yaml b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/overlay/plugins-list.yaml new file mode 100644 index 0000000..e69de29 diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/overlay/source.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/workspace/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/workspace/package.json new file mode 100644 index 0000000..00da58d --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported/output/workspace/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/workspace", + "version": "1.0.0", + "private": true +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/error b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/error new file mode 100644 index 0000000..d74797b --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/error @@ -0,0 +1 @@ +plugins-list.yaml not found \ No newline at end of file diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/overlay-root/rhdh-supported-packages.txt b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/overlay-root/rhdh-supported-packages.txt new file mode 100644 index 0000000..8faa355 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/overlay-root/rhdh-supported-packages.txt @@ -0,0 +1 @@ +throws-when-plugins-list-yaml-is-missing/plugins/foo diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/overlay/source.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/overlay/source.json new file mode 100644 index 0000000..eb1806a --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/monorepo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/workspace/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/workspace/package.json new file mode 100644 index 0000000..f30df30 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-plugins-list-yaml-is-missing/input/workspace/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/workspace", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/error b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/error new file mode 100644 index 0000000..8bf2040 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/error @@ -0,0 +1 @@ +rhdh-supported-packages.txt not found \ No newline at end of file diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/overlay/plugins-list.yaml b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/overlay/plugins-list.yaml new file mode 100644 index 0000000..0cfcb48 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/overlay/plugins-list.yaml @@ -0,0 +1 @@ +plugins/foo: diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/overlay/source.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/overlay/source.json new file mode 100644 index 0000000..eb1806a --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/monorepo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/workspace/package.json b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/workspace/package.json new file mode 100644 index 0000000..f30df30 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/__fixtures__/throws-when-rhdh-supported-packages-txt-is-missing/input/workspace/package.json @@ -0,0 +1,4 @@ +{ + "name": "@example/workspace", + "version": "1.0.0" +} diff --git a/prepare-sources/lib/modules/plugin-removal/index.test.ts b/prepare-sources/lib/modules/plugin-removal/index.test.ts new file mode 100644 index 0000000..5164960 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/index.test.ts @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vite-plus/test"; + +import { loadFixture, makeTempDir, testInputOutputExpectations } from "../../test-utils.ts"; +import { + collectEmbeddedPackageNames, + filterPluginsListBySupported, + normalizeWorkspacePath, + parsePluginsListYaml, + parseTierListFile, + removeInfrastructureDirs, + run, + shouldKeepPackageDir, +} from "./index.ts"; + +/** + * Fixture catalog (`__fixtures__//` — name doubles as the Vitest case title): + * + * - `preserves-supported-plugins-removes-infrastructure-and-rewrites-plugins-list` — + * whitelist filter (plugins/ and packages/ paths), infrastructure removal + * (examples/, packages/app, backend, app-next, .storybook, node_modules), + * dist-dynamic/ preserved for supported plugins, comments and blank lines kept in + * plugins-list rewrite, CLI args preserved on surviving entries + * - `preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin` — embedded + * package source kept via dist-dynamic/embedded name match; unrelated plugin removed + * - `removes-all-plugin-dirs-when-no-plugins-list-entries-are-supported` — empty + * surviving list; all plugin dirs removed + * - `throws-when-rhdh-supported-packages-txt-is-missing` — error when tier list absent + * - `throws-when-plugins-list-yaml-is-missing` — error when plugins-list absent + */ +describe("plugin-removal", () => { + testInputOutputExpectations(import.meta.dirname, run); +}); + +describe("parsePluginsListYaml", () => { + it("parses paths and preserves raw lines with CLI args", () => { + const entries = parsePluginsListYaml(` +# comment +plugins/foo: +plugins/bar: --embed-package @scope/pkg +`); + expect(entries).toEqual([ + { rawLine: "plugins/foo:", pluginPath: "plugins/foo" }, + { rawLine: "plugins/bar: --embed-package @scope/pkg", pluginPath: "plugins/bar" }, + ]); + }); + + it("skips lines with spaces in the plugin path", () => { + const entries = parsePluginsListYaml("some random text\nplugins/valid:"); + expect(entries).toEqual([{ rawLine: "plugins/valid:", pluginPath: "plugins/valid" }]); + }); +}); + +describe("filterPluginsListBySupported", () => { + it("keeps only entries present in the supported tier list", () => { + const entries = parsePluginsListYaml("plugins/a:\nplugins/b:"); + const supported = new Set(["ws/plugins/a"]); + expect(filterPluginsListBySupported(entries, "ws", supported).map((e) => e.pluginPath)).toEqual( + ["plugins/a"], + ); + }); +}); + +describe("normalizeWorkspacePath", () => { + it("strips trailing /.", () => { + expect(normalizeWorkspacePath("pagerduty/.")).toBe("pagerduty"); + expect(normalizeWorkspacePath("pagerduty/plugins/foo")).toBe("pagerduty/plugins/foo"); + }); +}); + +describe("parseTierListFile", () => { + it("skips comments and blank lines", () => { + expect(parseTierListFile("# header\n\nws/plugins/a\n")).toEqual(["ws/plugins/a"]); + }); +}); + +describe("removeInfrastructureDirs", () => { + it("skips file matches that are not directories", () => { + using tmp = makeTempDir(); + const packagesDir = path.join(tmp.path, "packages"); + fs.mkdirSync(packagesDir, { recursive: true }); + fs.writeFileSync(path.join(packagesDir, "backend"), "not a directory"); + const log = vi.fn(); + removeInfrastructureDirs(tmp.path, log); + expect(fs.existsSync(path.join(packagesDir, "backend"))).toBe(true); + expect(log).not.toHaveBeenCalled(); + }); +}); + +describe("collectEmbeddedPackageNames", () => { + it("skips malformed package.json in embedded dirs", () => { + using tmp = loadFixture( + import.meta.dirname, + "preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin", + ); + const embeddedDir = path.join( + tmp.ctx.workspacePath, + "plugins/exported-plugin/dist-dynamic/embedded/bad-pkg", + ); + fs.mkdirSync(embeddedDir, { recursive: true }); + fs.writeFileSync(path.join(embeddedDir, "package.json"), "{ broken"); + const names = collectEmbeddedPackageNames(tmp.ctx.workspacePath); + expect(names.has("@example/embedded-lib")).toBe(true); + expect(names.has("@example/another-lib")).toBe(true); + expect(names.size).toBe(2); + }); + + it("skips package.json without a name field", () => { + using tmp = loadFixture( + import.meta.dirname, + "preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin", + ); + const embeddedDir = path.join( + tmp.ctx.workspacePath, + "plugins/exported-plugin/dist-dynamic/embedded/no-name", + ); + fs.mkdirSync(embeddedDir, { recursive: true }); + fs.writeFileSync(path.join(embeddedDir, "package.json"), '{"version": "1.0.0"}'); + const names = collectEmbeddedPackageNames(tmp.ctx.workspacePath); + expect(names.has("@example/embedded-lib")).toBe(true); + expect(names.has("@example/another-lib")).toBe(true); + expect(names.size).toBe(2); + }); +}); + +describe("shouldKeepPackageDir", () => { + it("keeps listed plugin paths, dist-dynamic dirs, and embedded package names", () => { + const fixture = loadFixture( + import.meta.dirname, + "preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin", + ); + const keep = new Set(["plugins/exported-plugin"]); + const embedded = new Set(["@example/embedded-lib"]); + const ws = fixture.ctx.workspacePath; + + expect(shouldKeepPackageDir(`${ws}/plugins/exported-plugin`, ws, keep, embedded)).toBe(true); + expect( + shouldKeepPackageDir(`${ws}/plugins/exported-plugin/dist-dynamic`, ws, keep, embedded), + ).toBe(true); + expect(shouldKeepPackageDir(`${ws}/plugins/embedded-lib-source`, ws, keep, embedded)).toBe( + true, + ); + expect(shouldKeepPackageDir(`${ws}/plugins/remove-me`, ws, keep, embedded)).toBe(false); + }); + + it("returns false when package.json is malformed", () => { + using tmp = loadFixture( + import.meta.dirname, + "preserves-embedded-source-by-npm-name-and-removes-unrelated-plugin", + ); + const ws = tmp.ctx.workspacePath; + const badDir = `${ws}/plugins/bad-json`; + fs.mkdirSync(badDir, { recursive: true }); + fs.writeFileSync(`${badDir}/package.json`, "{ invalid json"); + expect(shouldKeepPackageDir(badDir, ws, new Set(), new Set(["anything"]))).toBe(false); + }); +}); diff --git a/prepare-sources/lib/modules/plugin-removal/index.ts b/prepare-sources/lib/modules/plugin-removal/index.ts new file mode 100644 index 0000000..0914735 --- /dev/null +++ b/prepare-sources/lib/modules/plugin-removal/index.ts @@ -0,0 +1,216 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { ModuleContext } from "../../pipeline.ts"; + +const SUPPORTED_PACKAGES_FILE = "rhdh-supported-packages.txt"; +const PLUGINS_LIST_FILE = "plugins-list.yaml"; + +/** Normalize workspace path entries (strips a trailing `/.` used for flat repos). */ +export function normalizeWorkspacePath(entry: string): string { + return entry.replace(/\/\.$/, ""); +} + +/** Parse non-comment, non-empty lines from a tier list text file. */ +export function parseTierListFile(content: string): string[] { + const entries: string[] = []; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + entries.push(normalizeWorkspacePath(trimmed)); + } + return entries; +} + +export type PluginsListEntry = { + /** Full line as written in plugins-list.yaml (path + optional CLI args). */ + rawLine: string; + /** Plugin path relative to the workspace root (before `:`). */ + pluginPath: string; +}; + +/** + * Parse `plugins-list.yaml` lines. Comment and blank lines are skipped. + * Lines may end with `:` and optional export CLI arguments. + */ +export function parsePluginsListYaml(content: string): PluginsListEntry[] { + const entries: PluginsListEntry[] = []; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const withoutListMarker = trimmed.replace(/^-\s*/, ""); + const pluginPath = withoutListMarker.split(":")[0]?.trim(); + if (!pluginPath || pluginPath.includes(" ")) continue; + entries.push({ rawLine: line, pluginPath }); + } + return entries; +} + +/** + * Keep plugins-list entries whose `workspace/pluginPath` appears in the supported tier list. + */ +export function filterPluginsListBySupported( + entries: PluginsListEntry[], + workspaceName: string, + supportedPaths: ReadonlySet, +): PluginsListEntry[] { + return entries.filter((entry) => { + const fullPath = normalizeWorkspacePath(`${workspaceName}/${entry.pluginPath}`); + return supportedPaths.has(fullPath); + }); +} + +function readPackageName(filePath: string): string | undefined { + const raw: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (typeof raw === "object" && raw !== null && "name" in raw && typeof raw.name === "string") { + return raw.name; + } + return undefined; +} + +/** Collect npm package names from dist-dynamic embedded package.json files under the workspace. */ +export function collectEmbeddedPackageNames(workspacePath: string): Set { + const names = new Set(); + const pattern = path.join(workspacePath, "**/dist-dynamic/embedded/*/package.json"); + for (const pkgPath of fs.globSync(pattern)) { + try { + const name = readPackageName(pkgPath); + if (name) names.add(name); + } catch { + // skip malformed package.json — same tolerance as sync-midstream jq + } + } + return names; +} + +/** Discover `package.json` files at least two directory levels below the workspace root. */ +export function findPackageJsonDirs(workspacePath: string): string[] { + const results: string[] = []; + for (const pkgPath of fs.globSync(path.join(workspacePath, "**/package.json"))) { + const rel = path.relative(workspacePath, pkgPath); + const segments = rel.split(path.sep); + if (segments.length < 3) continue; + results.push(path.dirname(pkgPath)); + } + return results.toSorted((a, b) => a.localeCompare(b)); +} + +export function shouldKeepPackageDir( + packageDir: string, + workspacePath: string, + keepPluginPaths: ReadonlySet, + embeddedNames: ReadonlySet, +): boolean { + const relDir = path.relative(workspacePath, packageDir); + for (const keepPath of keepPluginPaths) { + if (relDir === keepPath || relDir === `${keepPath}/dist-dynamic`) { + return true; + } + } + const pkgJson = path.join(packageDir, "package.json"); + if (!fs.existsSync(pkgJson)) return false; + try { + const name = readPackageName(pkgJson); + return name !== undefined && embeddedNames.has(name); + } catch { + return false; + } +} + +/** Remove infrastructure directories not needed for supported plugin builds. */ +export function removeInfrastructureDirs(workspacePath: string, log: (msg: string) => void): void { + const examplesDir = path.join(workspacePath, "examples"); + if (fs.existsSync(examplesDir) && fs.statSync(examplesDir).isDirectory()) { + fs.rmSync(examplesDir, { recursive: true, force: true }); + log("removed examples/"); + } + + const infraPatterns = [ + path.join(workspacePath, "**/packages/backend"), + path.join(workspacePath, "**/packages/app"), + path.join(workspacePath, "**/packages/app-next"), + path.join(workspacePath, "**/.storybook"), + path.join(workspacePath, "**/node_modules"), + ]; + for (const pattern of infraPatterns) { + for (const dir of fs.globSync(pattern)) { + if (!fs.statSync(dir).isDirectory()) continue; + fs.rmSync(dir, { recursive: true, force: true }); + log(`removed ${path.relative(workspacePath, dir)}`); + } + } +} + +/** + * Rewrite `plugins-list.yaml` keeping comments and blank lines intact, + * removing only uncommented plugin entries that didn't survive filtering. + */ +function writePluginsList( + overlayPath: string, + originalContent: string, + surviving: PluginsListEntry[], +): void { + const survivingRawLines = new Set(surviving.map((e) => e.rawLine)); + const outputLines: string[] = []; + for (const line of originalContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + outputLines.push(line); + continue; + } + if (survivingRawLines.has(line)) { + outputLines.push(line); + } + } + fs.writeFileSync(path.join(overlayPath, PLUGINS_LIST_FILE), outputLines.join("\n")); +} + +/** + * Removes non-supported plugin source from the workspace and rewrites + * `plugins-list.yaml` to list only surviving supported plugins. + */ +export function run(ctx: ModuleContext): Promise { + const workspaceName = path.basename(ctx.overlayPath); + const supportedFile = path.join(ctx.overlayRepoRoot, SUPPORTED_PACKAGES_FILE); + const pluginsListPath = path.join(ctx.overlayPath, PLUGINS_LIST_FILE); + + if (!fs.existsSync(supportedFile)) { + return Promise.reject(new Error(`${SUPPORTED_PACKAGES_FILE} not found at overlay repo root`)); + } + if (!fs.existsSync(pluginsListPath)) { + return Promise.reject(new Error(`${PLUGINS_LIST_FILE} not found in overlay path`)); + } + + const supportedPaths = new Set(parseTierListFile(fs.readFileSync(supportedFile, "utf8"))); + const pluginsListContent = fs.readFileSync(pluginsListPath, "utf8"); + const allListEntries = parsePluginsListYaml(pluginsListContent); + const survivingList = filterPluginsListBySupported(allListEntries, workspaceName, supportedPaths); + + ctx.log( + `plugins-list.yaml: ${survivingList.length}/${allListEntries.length} entries supported for workspace ${workspaceName}`, + ); + + const keepPluginPaths = new Set( + survivingList.map((entry) => normalizeWorkspacePath(entry.pluginPath)), + ); + const embeddedNames = collectEmbeddedPackageNames(ctx.workspacePath); + if (embeddedNames.size > 0) { + ctx.log( + `embedded packages to preserve: ${[...embeddedNames].toSorted((a, b) => a.localeCompare(b)).join(", ")}`, + ); + } + + removeInfrastructureDirs(ctx.workspacePath, ctx.log); + + for (const packageDir of findPackageJsonDirs(ctx.workspacePath)) { + if (shouldKeepPackageDir(packageDir, ctx.workspacePath, keepPluginPaths, embeddedNames)) { + continue; + } + const rel = path.relative(ctx.workspacePath, packageDir); + fs.rmSync(packageDir, { recursive: true, force: true }); + ctx.log(`removed ${rel}`); + } + + writePluginsList(ctx.overlayPath, pluginsListContent, survivingList); + ctx.log(`written filtered ${PLUGINS_LIST_FILE}`); + return Promise.resolve(); +} diff --git a/prepare-sources/lib/pipeline.test.ts b/prepare-sources/lib/pipeline.test.ts index 7872d2b..6bdfd55 100644 --- a/prepare-sources/lib/pipeline.test.ts +++ b/prepare-sources/lib/pipeline.test.ts @@ -14,7 +14,8 @@ import { makeTempDir } from "./test-utils.ts"; const inputs: PipelineInputs = { workspacePath: "/tmp/ws", - overlayPath: "/tmp/overlay", + overlayPath: "/tmp/overlay/workspaces/test", + overlayRepoRoot: "/tmp/overlay", source: { repo: "https://example.com/repo", "repo-ref": "main", @@ -61,6 +62,7 @@ describe("loadPipelineInputs", () => { expect(result.source["repo-ref"]).toBe("abc123"); expect(result.workspacePath).toBe(workspaceDir.path); expect(result.overlayPath).toBe(overlayDir.path); + expect(result.overlayRepoRoot).toBe(path.resolve(overlayDir.path, "../..")); }); it("throws when workspace path does not exist", () => { diff --git a/prepare-sources/lib/pipeline.ts b/prepare-sources/lib/pipeline.ts index c52b689..a78c12a 100644 --- a/prepare-sources/lib/pipeline.ts +++ b/prepare-sources/lib/pipeline.ts @@ -6,6 +6,8 @@ import { readSourceFile, type SourceJson } from "./source.ts"; export interface PipelineInputs { workspacePath: string; overlayPath: string; + /** Overlay repository root (`overlay-repo/`), parent of `workspaces/`. */ + overlayRepoRoot: string; source: SourceJson; } @@ -34,8 +36,9 @@ export function loadPipelineInputs(workspacePath: string, overlayPath: string): } const source = readSourceFile(path.join(resolved.overlayPath, "source.json")); + const overlayRepoRoot = path.resolve(resolved.overlayPath, "../.."); - return { ...resolved, source }; + return { ...resolved, overlayRepoRoot, source }; } export function selectModules( diff --git a/prepare-sources/lib/test-utils.ts b/prepare-sources/lib/test-utils.ts index 99d94b6..8a08a93 100644 --- a/prepare-sources/lib/test-utils.ts +++ b/prepare-sources/lib/test-utils.ts @@ -156,9 +156,9 @@ function dirAssertions(baseDir: string): DirAssertions { * __fixtures__// * ├── root/ → (optional) repo-root files, see below * ├── input/ - * │ ├── workspace/ → copied into a temp workspace dir - * │ └── overlay/ → copied into a temp overlay dir - * │ └── source.json → parsed into ctx.source + * │ ├── workspace/ → temp workspace (use plugins/, packages/, …) + * │ ├── overlay/ → workspace overlay (plugins-list.yaml, source.json) + * │ └── overlay-root/ → (optional) overlay repo root (tier lists, …) * └── output/ * ├── workspace/ → asserted against workspace after run * └── overlay/ → asserted against overlay after run @@ -177,6 +177,20 @@ function dirAssertions(baseDir: string): DirAssertions { * - If `output//` exists → assert it matches that directory. * - If `output//` is absent → assert no changes from `input//`. * + * **Workspace layout:** Prefer realistic monorepo shapes under `input/workspace/`: + * exportable plugins in `plugins//`, non-plugin packages in + * `packages//`, infrastructure shells in `packages/app` (etc.). Match + * `plugins-list.yaml` paths to that layout (`plugins/foo:` for Backstage-style + * workspaces; `packages/foo:` for flat repos such as gitlab). Include + * `dist-dynamic/` under a plugin when the scenario needs post-export state. + * Avoid arbitrary nesting (e.g. `lib/foo/`) unless the test targets that edge case. + * + * **Overlay root:** When `input/overlay-root/` exists (parallel to `root/` for + * the source repo), its contents are copied to a temp overlay repo root and + * `input/overlay/` is placed at `workspaces//` (the fixture + * directory name is the workspace name for tier-list entries like + * `/plugins/foo`). + * * @param testDir Pass `import.meta.dirname` from your test file. * @param name Fixture subdirectory name under the fixtures dir. * @param fixturesDir Subdirectory name containing fixtures (default: `__fixtures__`). @@ -192,7 +206,8 @@ export function loadFixture( } const workspaceDir = makeTempDir(); - const overlayDir = makeTempDir(); + let overlayDir: TempDir; + let overlayRootDir: TempDir | undefined; let workspacePath = workspaceDir.path; let repoRootDir: TempDir | undefined; @@ -201,7 +216,19 @@ export function loadFixture( fs.cpSync(inputWorkspace, workspaceDir.path, { recursive: true }); const inputOverlay = path.join(fixtureDir, "input", "overlay"); - if (fs.existsSync(inputOverlay)) fs.cpSync(inputOverlay, overlayDir.path, { recursive: true }); + const inputOverlayRoot = path.join(fixtureDir, "input", "overlay-root"); + + if (fs.existsSync(inputOverlayRoot)) { + overlayRootDir = makeTempDir(); + fs.cpSync(inputOverlayRoot, overlayRootDir.path, { recursive: true }); + const overlayPath = path.join(overlayRootDir.path, "workspaces", name); + fs.mkdirSync(overlayPath, { recursive: true }); + if (fs.existsSync(inputOverlay)) fs.cpSync(inputOverlay, overlayPath, { recursive: true }); + overlayDir = { path: overlayPath, [Symbol.dispose]() {} }; + } else { + overlayDir = makeTempDir(); + if (fs.existsSync(inputOverlay)) fs.cpSync(inputOverlay, overlayDir.path, { recursive: true }); + } const fixtureRootDir = path.join(fixtureDir, "root"); if (fs.existsSync(fixtureRootDir)) { @@ -218,10 +245,12 @@ export function loadFixture( throw new Error(`fixture ${name} is missing input/overlay/source.json`); } const source = readSourceFile(sourcePath); + const overlayRepoRoot = overlayRootDir?.path ?? path.resolve(overlayDir.path, "../.."); const ctx: ModuleContext = { workspacePath, overlayPath: overlayDir.path, + overlayRepoRoot, source, log: vi.fn(), }; @@ -252,6 +281,7 @@ export function loadFixture( [Symbol.dispose]() { workspaceDir[Symbol.dispose](); repoRootDir?.[Symbol.dispose](); + overlayRootDir?.[Symbol.dispose](); overlayDir[Symbol.dispose](); }, }; From 0e0017d5b0f70a12e7e97bff8612f78a1b05f9b3 Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 14 Sep 2026 20:40:28 +0200 Subject: [PATCH 3/3] feat(prepare-rhdh-supported-sources): implement `file-cleanup` pipeline module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove test, mock, and dev-only files from surviving workspace packages: directories `dev/`, `e2e-tests/`, `__tests__/`, `__mocks__/` and files matching `*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`, while preserving paths under `node_modules/` and `dist-dynamic/`. Aligns with `sync-midstream.sh` lines 917–924. Add the module implementation, fixture-based tests, fixtures, and README.md; wire the module into the pipeline runner. Exclude `__fixtures__` directories from Vitest discovery in `vite.config.ts`. Assisted-by: Cursor Signed-off-by: David Festal --- prepare-sources/lib/modules.ts | 3 +- .../lib/modules/file-cleanup/README.md | 70 +++++++++++++++++ .../input/overlay/source.json | 6 ++ .../my-plugin/dist-dynamic/embedded.test.ts | 1 + .../my-plugin/src/__tests__/keep-dir.ts | 1 + .../plugins/my-plugin/src/index.test.ts | 1 + .../workspace/plugins/my-plugin/src/index.ts | 1 + .../my-plugin/dist-dynamic/embedded.test.ts | 1 + .../workspace/plugins/my-plugin/src/index.ts | 1 + .../input/overlay/source.json | 6 ++ .../plugins/my-plugin/__mocks__/mock.ts | 1 + .../workspace/plugins/my-plugin/dev/utils.ts | 1 + .../plugins/my-plugin/e2e-tests/spec.ts | 1 + .../my-plugin/src/__tests__/inside.test.ts | 1 + .../plugins/my-plugin/src/__tests__/nested.ts | 1 + .../plugins/my-plugin/src/bar.spec.ts | 1 + .../plugins/my-plugin/src/foo.spec.tsx | 1 + .../plugins/my-plugin/src/foo.test.tsx | 1 + .../plugins/my-plugin/src/index.mock.ts | 1 + .../plugins/my-plugin/src/index.test.ts | 1 + .../workspace/plugins/my-plugin/src/index.ts | 1 + .../my-plugin/src/stories/Button.stories.tsx | 1 + .../plugins/my-plugin/src/index.mock.ts | 1 + .../workspace/plugins/my-plugin/src/index.ts | 1 + .../my-plugin/src/stories/Button.stories.tsx | 1 + .../lib/modules/file-cleanup/index.test.ts | 18 +++++ .../lib/modules/file-cleanup/index.ts | 78 +++++++++++++++++++ prepare-sources/vite.config.ts | 2 + 28 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 prepare-sources/lib/modules/file-cleanup/README.md create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/dist-dynamic/embedded.test.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/__tests__/keep-dir.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/index.test.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/index.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/output/workspace/plugins/my-plugin/dist-dynamic/embedded.test.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/output/workspace/plugins/my-plugin/src/index.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/overlay/source.json create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/__mocks__/mock.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/dev/utils.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/e2e-tests/spec.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/__tests__/inside.test.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/__tests__/nested.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/bar.spec.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/foo.spec.tsx create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/foo.test.tsx create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.mock.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.test.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/stories/Button.stories.tsx create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/index.mock.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/index.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/stories/Button.stories.tsx create mode 100644 prepare-sources/lib/modules/file-cleanup/index.test.ts create mode 100644 prepare-sources/lib/modules/file-cleanup/index.ts diff --git a/prepare-sources/lib/modules.ts b/prepare-sources/lib/modules.ts index bfd37a1..361477f 100644 --- a/prepare-sources/lib/modules.ts +++ b/prepare-sources/lib/modules.ts @@ -3,6 +3,7 @@ import { run as hermeticPrep } from "./modules/hermetic-prep/index.ts"; import { run as makeSelfContained } from "./modules/make-self-contained/index.ts"; import { run as generateManifests } from "./modules/generate-manifests/index.ts"; import { run as pluginRemoval } from "./modules/plugin-removal/index.ts"; +import { run as fileCleanup } from "./modules/file-cleanup/index.ts"; async function notImplemented(ctx: ModuleContext): Promise { ctx.log("not yet implemented"); @@ -14,7 +15,7 @@ export const MODULES: readonly PipelineModule[] = [ { name: "make-self-contained", run: makeSelfContained }, { name: "generate-manifests", run: generateManifests }, { name: "plugin-removal", run: pluginRemoval }, - { name: "file-cleanup", run: notImplemented }, + { name: "file-cleanup", run: fileCleanup }, { name: "protocol-resolution", run: notImplemented }, { name: "package-cleanup", run: notImplemented }, { name: "hermetic-prep", run: hermeticPrep }, diff --git a/prepare-sources/lib/modules/file-cleanup/README.md b/prepare-sources/lib/modules/file-cleanup/README.md new file mode 100644 index 0000000..a8006bb --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/README.md @@ -0,0 +1,70 @@ +# file-cleanup + +Removes test, mock, and dev-only files from surviving workspace packages after +`plugin-removal`. Reduces source size and avoids TypeScript errors from test +files that reference devDependencies removed during scrubbing. + +**Pipeline position:** after `plugin-removal`, before `protocol-resolution` — +see `modules.ts` for the current ordering. + +## Problem + +Surviving plugin directories still contain test specs, mock directories, and +local dev utilities. Downstream Konflux builds do not run tests; these files +only add noise and can fail `tsc` when they import types from scrubbed packages +(for example `@types/jest`). + +## What the module does + +Recursively scans `ctx.workspacePath` and removes: + +| Kind | Patterns | +| ------------------------- | ---------------------------------------------------- | +| Directories (entire tree) | `dev/`, `e2e-tests/`, `__tests__/`, `__mocks__/` | +| Files | `*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx` | + +Paths under `node_modules/` or `dist-dynamic/` are never touched. + +## Inputs and outputs + +| Input | Location | +| ------------------ | ------------------- | +| Scrubbed workspace | `ctx.workspacePath` | + +| Output | Location | +| ----------------- | ------------------------------ | +| Cleaned workspace | `ctx.workspacePath` (in place) | + +No overlay files are read or written. + +## Non-obvious behavior + +**Full workspace scan.** The bash original searches the entire workspace tree +(`$folder_name`), not only `plugins/`. This module does the same — a stray +`packages/shared/src/foo.test.ts` is removed too. + +**Directory before file.** Removable directories are deleted deepest-first so +nested content is not processed twice. + +**dist-dynamic is preserved.** Test files inside `dist-dynamic/` are kept +because the downstream export step may reference lockfile metadata there; bash +explicitly excludes `*/dist-dynamic/*`. + +## Differences from design-decisions summary + +The pipeline table mentions `*.stories.*` and `*.mock.*`; the legacy +`sync-midstream.sh` `find` command does not remove those. This module follows +the bash script (lines 917–924), not the broader table. + +## Error conditions + +This module does not throw for missing paths. An empty workspace is a no-op. + +## Tests + +Fixture-based I/O tests under `__fixtures__/`. Each subdirectory name is the +Vitest case title; see the fixture catalog comment in `index.test.ts`. + +Typical layout: surviving plugin under `plugins//` with production +source, test dirs, and spec files. Include `dist-dynamic/` and `node_modules/` +subtrees when testing exclusion boundaries. diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/overlay/source.json b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/dist-dynamic/embedded.test.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/dist-dynamic/embedded.test.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/dist-dynamic/embedded.test.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/__tests__/keep-dir.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/__tests__/keep-dir.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/__tests__/keep-dir.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/index.test.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/index.test.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/index.test.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/index.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/index.ts new file mode 100644 index 0000000..0de78a8 --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/input/workspace/plugins/my-plugin/src/index.ts @@ -0,0 +1 @@ +export const plugin = "production"; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/output/workspace/plugins/my-plugin/dist-dynamic/embedded.test.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/output/workspace/plugins/my-plugin/dist-dynamic/embedded.test.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/output/workspace/plugins/my-plugin/dist-dynamic/embedded.test.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/output/workspace/plugins/my-plugin/src/index.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/output/workspace/plugins/my-plugin/src/index.ts new file mode 100644 index 0000000..0de78a8 --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/preserves-files-under-dist-dynamic-and-node-modules/output/workspace/plugins/my-plugin/src/index.ts @@ -0,0 +1 @@ +export const plugin = "production"; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/overlay/source.json b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/overlay/source.json new file mode 100644 index 0000000..2d01d75 --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/overlay/source.json @@ -0,0 +1,6 @@ +{ + "repo": "https://github.com/example/repo", + "repo-ref": "abc123", + "repo-flat": false, + "repo-backstage-version": "1.45.1" +} diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/__mocks__/mock.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/__mocks__/mock.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/__mocks__/mock.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/dev/utils.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/dev/utils.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/dev/utils.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/e2e-tests/spec.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/e2e-tests/spec.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/e2e-tests/spec.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/__tests__/inside.test.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/__tests__/inside.test.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/__tests__/inside.test.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/__tests__/nested.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/__tests__/nested.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/__tests__/nested.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/bar.spec.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/bar.spec.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/bar.spec.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/foo.spec.tsx b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/foo.spec.tsx new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/foo.spec.tsx @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/foo.test.tsx b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/foo.test.tsx new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/foo.test.tsx @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.mock.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.mock.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.mock.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.test.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.test.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.test.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.ts new file mode 100644 index 0000000..0de78a8 --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/index.ts @@ -0,0 +1 @@ +export const plugin = "production"; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/stories/Button.stories.tsx b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/stories/Button.stories.tsx new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/input/workspace/plugins/my-plugin/src/stories/Button.stories.tsx @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/index.mock.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/index.mock.ts new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/index.mock.ts @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/index.ts b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/index.ts new file mode 100644 index 0000000..0de78a8 --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/index.ts @@ -0,0 +1 @@ +export const plugin = "production"; diff --git a/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/stories/Button.stories.tsx b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/stories/Button.stories.tsx new file mode 100644 index 0000000..1bda49b --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/__fixtures__/removes-test-and-dev-files-from-surviving-plugins/output/workspace/plugins/my-plugin/src/stories/Button.stories.tsx @@ -0,0 +1 @@ +export const placeholder = true; diff --git a/prepare-sources/lib/modules/file-cleanup/index.test.ts b/prepare-sources/lib/modules/file-cleanup/index.test.ts new file mode 100644 index 0000000..7e96734 --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/index.test.ts @@ -0,0 +1,18 @@ +import { describe } from "vite-plus/test"; + +import { testInputOutputExpectations } from "../../test-utils.ts"; +import { run } from "./index.ts"; + +/** + * Fixture catalog (`__fixtures__//` — name doubles as the Vitest case title): + * + * - `removes-test-and-dev-files-from-surviving-plugins` — all removable dir names + * (`dev/`, `e2e-tests/`, `__tests__/`, `__mocks__/`), all test/spec file patterns + * (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`); production source, + * stories, and `*.mock.ts` kept + * - `preserves-files-under-dist-dynamic-and-node-modules` — exclusion boundaries + * for dist-dynamic/ and node_modules/ + */ +describe("file-cleanup", () => { + testInputOutputExpectations(import.meta.dirname, run); +}); diff --git a/prepare-sources/lib/modules/file-cleanup/index.ts b/prepare-sources/lib/modules/file-cleanup/index.ts new file mode 100644 index 0000000..77fc78c --- /dev/null +++ b/prepare-sources/lib/modules/file-cleanup/index.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { ModuleContext } from "../../pipeline.ts"; + +/** Directory basenames removed wholesale (sync-midstream.sh find -type d). */ +export const REMOVABLE_DIR_NAMES = new Set(["dev", "e2e-tests", "__tests__", "__mocks__"]); + +const TEST_FILE_PATTERNS = [/\.test\.ts$/, /\.test\.tsx$/, /\.spec\.ts$/, /\.spec\.tsx$/]; + +/** True when a relative workspace path is under node_modules or dist-dynamic. */ +export function isExcludedPath(relativePath: string): boolean { + const normalized = relativePath.split(path.sep).join("/"); + return normalized.includes("/node_modules/") || normalized.includes("/dist-dynamic/"); +} + +export function shouldRemoveDir(dirName: string): boolean { + return REMOVABLE_DIR_NAMES.has(dirName); +} + +export function shouldRemoveFile(fileName: string): boolean { + return TEST_FILE_PATTERNS.some((pattern) => pattern.test(fileName)); +} + +function walkEntries(dir: string): string[] { + const results: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + results.push(fullPath); + if (entry.isDirectory()) { + results.push(...walkEntries(fullPath)); + } + } + return results; +} + +export function findRemovablePaths(workspacePath: string): { dirs: string[]; files: string[] } { + const dirs: string[] = []; + const files: string[] = []; + + for (const entryPath of walkEntries(workspacePath)) { + const rel = path.relative(workspacePath, entryPath); + if (isExcludedPath(rel)) continue; + + const stat = fs.statSync(entryPath); + const baseName = path.basename(entryPath); + + if (stat.isDirectory() && shouldRemoveDir(baseName)) { + dirs.push(entryPath); + } else if (stat.isFile() && shouldRemoveFile(baseName)) { + files.push(entryPath); + } + } + + dirs.sort((a, b) => b.length - a.length); + return { dirs, files }; +} + +/** + * Removes test, mock, and dev-only files from surviving workspace packages. + * Matches sync-midstream.sh lines 917–924. + */ +export function run(ctx: ModuleContext): Promise { + const { dirs, files } = findRemovablePaths(ctx.workspacePath); + + for (const dir of dirs) { + fs.rmSync(dir, { recursive: true, force: true }); + ctx.log(`removed ${path.relative(ctx.workspacePath, dir)}/`); + } + + for (const file of files) { + // Files under removed dirs were already deleted with their parent. + if (!fs.existsSync(file)) continue; + fs.rmSync(file, { force: true }); + ctx.log(`removed ${path.relative(ctx.workspacePath, file)}`); + } + + return Promise.resolve(); +} diff --git a/prepare-sources/vite.config.ts b/prepare-sources/vite.config.ts index 1465332..db9ae56 100644 --- a/prepare-sources/vite.config.ts +++ b/prepare-sources/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ lint: { + ignorePatterns: ["**/__fixtures__/**"], categories: { correctness: "error", suspicious: "error", @@ -24,5 +25,6 @@ export default defineConfig({ 100: true, }, }, + exclude: ["**/node_modules/**", "**/__fixtures__/**"], }, });