diff --git a/.config/purview-build.json b/.config/purview-build.json index 94074a7..aba3266 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -32,5 +32,8 @@ }, "Release": { "Mode": "None" + }, + "PackageJson": { + "Enabled": true } -} \ No newline at end of file +} diff --git a/AGENTS.md b/AGENTS.md index c5f64fc..f4f301b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,8 @@ This file is the primary instruction set for human and AI agents working in this - `docs/wiki/Release-Flow.md` — versioning, branch models, and release strategy. - `docs/wiki/Release-Models.md` — the three release trigger models, as raw Mermaid source. - `docs/wiki/Local-Development.md` — `just` recipes, `release-explain`, simulation, and the local rehearsal. + - `docs/wiki/Pack-Validation.md` — the `.nupkg`/`.snupkg` content rules. + - `docs/wiki/Package-Json-Validation.md` — the root `package.json` profiles, the `PKG0nn` rules, and `package-validate`. - `docs/wiki/Net-11.md` — supported runtimes (and the .NET 8/9 removal) and the .NET 11 feature evaluation. - `docs/wiki/Migration-*.md` — per-consumer migration guides. - `README.md` — user-facing overview and minimal consumer setup. @@ -42,6 +44,7 @@ The developer entry points are the `just` recipes; the .NET SDK and `just` are r - `just test` / `just test-unit` — run tests under `src/tests`. CI and `just test` both run **everything**: `Build:TestFilter` in `purview-build.json` is `/*/*/*/*/`. `just test-unit` narrows to the unit suites for a faster local loop. - `just test-eligibility` — the eligibility and config-resolution suites only. - `just release-matrix` / `just config-matrix` — run every scenario through the real CLI. **Run both before any change to release or configuration behaviour**; they need no secrets and no network. +- `just package-matrix` — run every `package.json` validation scenario through the real CLI. Run it before any change to the `PKG0nn` rules; it needs no secrets and no network. - `just release-explain` / `just release-simulate ` — explain the release decision, mutating nothing. - `just lint-yaml` — `actionlint` over the workflow files (skipped with a note when not installed). - `just lint-check` / `just lint-fix` — CSharpier (via local tool manifest `.config/dotnet-tools.json`). @@ -64,7 +67,7 @@ CI builds with `--warnaserror`. - **Versioning**: the single source of truth is the `version` field in `package.json`. `VersionModule` resolves it once into an ordered set of release units; every downstream module reads that result and **never recomputes a version**. `Version:Strictness` defaults to `NuGet`, which accepts four-part versions — two consuming repositories ship them, so do not tighten this default. - **Eligibility ownership**: the tool *evaluates* release eligibility and reports it; the workflow *decides* and sets `Release__Mode`. The tool must never silently decline to release when asked to. `ReportEligibilityModule` is category `Build` and every failure path in it is a warning. -- **Rule IDs are permanent**: never renumber or repurpose a `REL0nn`. +- **Rule IDs are permanent**: never renumber or repurpose a `REL0nn` or a `PKG0nn`. - **This repository builds itself and never references its own released package.** The `Justfile` runs `.tools/local/Purview.Build`, published from the working tree; `ci.yml` and `release.yml` pack from source and install with `--source artifacts` (not `--add-source`, which leaves nuget.org able to satisfy an already-shipped version); `Purview.Build` is absent from `.config/dotnet-tools.json`. `SelfHostingTests` enforces all three. The reusable workflows and the composite action are the opposite case — they install the published tool for consuming repositories and must keep doing so. - **GitHub Packages is additive and opt-in.** Consuming from `https://nuget.pkg.github.com//index.json` is automatic for every repository using the shared action/workflows; publishing to it is controlled only by the caller repository's `PURVIEW_PUBLISH_GITHUB_PACKAGES` variable, which the release entry points map onto `NuGet:GitHubPackages:Enabled`. Never make publication automatic, never make it a replacement for NuGet.org, and never put a PAT in the shared path for publishing: publication uses `GITHUB_TOKEN` with `packages: write`. Consumption falls back to `GITHUB_TOKEN` and may use the optional, read-only organisation secret `PURVIEW_PACKAGES_READ_TOKEN` (`read:packages` only) for repositories that should read every package without per-package Actions-access grants. NuGet.org publication and `Release:Mode` semantics must not change. - **One source-setup implementation.** `actions/nuget-sources/` is the only place that registers the organisation feed. `purview-build.yml`, `purview-release.yml`, and both composite actions call it; `ci.yml` and `release.yml` call it from `./` to dogfood it. It writes the runner's user-level NuGet configuration (never a committed `nuget.config`) and stores only a macro — `%GITHUB_TOKEN%`, or `%PURVIEW_NUGET_TOKEN%` when the optional organisation read token is used. @@ -123,6 +126,7 @@ npx skills add purview-dev/build | `skills/purview-build-configuration/` | where `purview-build.json` is found, precedence, every setting, the secrets | | `skills/purview-build-pipeline/` | what each module does, its dependencies, and why it was skipped | | `skills/purview-build-pack-validation/` | the package rules, the target-framework token, and `RequireExplicitContent` | +| `skills/purview-build-package-json/` | the root `package.json` profiles, the `PKG0nn` rules, and `package-validate` | | `skills/purview-build-release-eligibility/` | the `REL0nn` rules, the built-in policies, and the three release models | Every skill carries the `purview-build` prefix. Skills install into one flat directory per agent, diff --git a/Justfile b/Justfile index 184575e..3202a4f 100644 --- a/Justfile +++ b/Justfile @@ -121,6 +121,7 @@ publish-aot: -p:Version={{ current_version }} -p:PackageVersion={{ current_version }} "{{ aot_dir }}/Purview.Build" --version "{{ aot_dir }}/Purview.Build" release-explain --format=json | jq -e '.verdict' + "{{ aot_dir }}/Purview.Build" package-validate --format=json | jq -e '.succeeded' # Explain the release decision for the working tree, without running any module or mutating anything [group('Release')] @@ -147,6 +148,12 @@ config-matrix: just build-local-tool sh src/tests/scenarios/run-config-matrix.sh +# Run every package.json validation scenario through the real CLI; non-zero on any mismatch +[group('Build and Test')] +package-matrix: + just build-local-tool + sh src/tests/scenarios/run-package-matrix.sh + # Regenerate the release-explain JSON golden file, then show what changed [group('Release')] release-explain-golden: diff --git a/README.md b/README.md index ff2230c..943f140 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,8 @@ Secrets must not be committed. They are supplied through `NUGET_APIKEY` (or `NuG Release eligibility is rule-based and selected per repository with `Release:Eligibility:Policy`: `ReleaseOnMain` (the default, reproducing the pipeline's long-standing behaviour), `TrunkReservesMinor` (per-line release branches), or `FourPartServicing`. Run `purview-build release-explain` to see the decision, the rule trail and the publication that would result, without running anything. See [release models](docs/wiki/Release-Models.md). +The root `package.json` can be checked against the content a best-in-class package of the repository's kind is expected to declare: run `purview-build package-validate` (exit `0`/`1`, `--format=json` for a workflow), and set `PackageJson:Enabled=true` to add it to the pipeline. A `.NET` repository that packs, one that does not, a published npm package and a web application are judged differently — descriptions, versions, licence, repository, author, engines, scripts and the `private` flag — and every rule is reported, in ID order. See [package.json validation](docs/wiki/Package-Json-Validation.md). + See the [Documentation](#documentation) section below for the architecture, configuration reference, and release strategy. ## GitHub Packages @@ -403,12 +405,12 @@ Other repositories can opt in later with only steps 1–2; nothing in Purview.Bu ## Pipeline ```text -CleanArtifacts → { Version, Restore → Build → Test, Restore → Lint } → Pack → Validate → Publish ─┬→ NuGet.org - └→ ReportEligibility └→ GitHub Packages (opt-in) - → GitHub release +CleanArtifacts → { Version → ValidatePackageJson, Restore → Build → Test, Restore → Lint } → Pack → Validate → Publish ─┬→ NuGet.org + └→ ReportEligibility └→ GitHub Packages (opt-in) + → GitHub release ``` -`Lint` and `ReportEligibility` are gates and reports, not inputs: nothing depends on either. +`Lint`, `ReportEligibility` and `ValidatePackageJson` are gates and reports, not inputs: nothing depends on any of them. `CleanArtifacts` deletes and recreates `Build:ArtifactsFolder` before anything else runs, so pack, validation, publishing, and release uploads only ever see the packages from the current run (set `Build:CleanArtifacts=false` to keep existing artifacts). `Version` resolves the release units from `Version:Source` (default: the `version` field of the root `package.json`); `Version:Strictness` defaults to `NuGet`, which accepts four-part versions. `ReportEligibility` evaluates the release rules and reports the verdict without acting on it. Lint restores local tools and runs CSharpier (`Build:CSharpierPath` runs a vendored CSharpier assembly instead, for an unreleased build). Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Analyzer-only packages can embed portable PDBs under `analyzers/dotnet/` without requiring a `.snupkg`. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) — a preset over the independent `Release:Publish` and `Release:GitHubRelease` switches — and by the `Build__Run*` switches. `Release:DryRun` runs everything and skips both publish steps. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow). diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 238aef4..744aa31 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -54,9 +54,11 @@ CleanArtifactsModule → RestoreModule → BuildModule → RunTestsModule ── CleanArtifactsModule → RestoreModule → LintModule │ └→ PublishGitHubPackagesModule ─┤ CleanArtifactsModule → VersionModule ─────────────────────────────────┤ └→ CreateGitHubReleaseModule VersionModule → ReportEligibilityModule │ + VersionModule → ValidatePackageJsonModule │ │ - (LintModule and ReportEligibilityModule - are not inputs to PackModule) + (LintModule, ReportEligibilityModule and + ValidatePackageJsonModule are not inputs + to PackModule) ``` Explicit `[DependsOn]` edges: @@ -66,6 +68,7 @@ Explicit `[DependsOn]` edges: - `LintModule` depends on `RestoreModule` (Web lint needs the dependencies installed by `bun install`; dotnet lint is unaffected beyond running after restore). **Nothing depends on `LintModule`** — it is a gate, not an input. - `RunTestsModule` depends on `BuildModule`. - `ReportEligibilityModule` depends on `VersionModule`. Nothing depends on it; it only reports. +- `ValidatePackageJsonModule` depends on `VersionModule` and is skipped unless `PackageJson:Enabled` is true. Nothing depends on it either: like `LintModule` it is a gate, so enabling it can never reorder pack or publish. The same check is reachable as the standalone `package-validate` command, which builds no pipeline at all. - `PackModule` depends on `RunTestsModule` and `VersionModule`. - `ValidatePackModule` depends on `PackModule`. - `PublishNuGetModule` depends on `PackModule`, `ValidatePackModule`, and `RunTestsModule`. diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 509e3da..48d3e5b 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -124,6 +124,7 @@ command line | `-v`, `--version` | Print the tool version and exit without running the pipeline. | | `-h`, `--help`, `-?` | Print usage, options, the probe order, and the resolved configuration path (or "none found"), then exit. | | `release-explain` | Print the release decision and exit without running any module. `--format=json` emits the stable contract the release action consumes. See [Local Development](Local-Development.md). | +| `package-validate` | Validate the root `package.json` against the rules for the repository's kind and exit without running any module: `0` when it passes, `1` when it does not. `--format=json` emits the report. See [package.json Validation](Package-Json-Validation.md). | Failures are reported the way a CLI build tool reports them: the tool prints the failing module and that module's output, then exits with code 1. Set `PURVIEW_BUILD_STACKTRACE=1` to add stack traces when diagnosing the tool itself. @@ -180,6 +181,19 @@ Content entry paths and package-id keys are matched as globs (case-insensitive), > > `RequireExplicitContent` is the opposite case and was previously documented incorrectly as `false`. It was **absent** from `appsettings.json`, so the C# initialiser's `true` survived and the effective default has always been `true`. It is now stated explicitly in `appsettings.json` so the shipped value is visible rather than inferred. Behaviour is unchanged. Note what `true` means with an empty `RequiredContent`: every produced package must declare its content, so a repository that packs anything without a matching `RequiredContent` rule gets an error. Set `"RequireExplicitContent": false` to opt out, as this repository's own `purview-build.json` does. +## `PackageJson` + +Opt-in validation of the repository root `package.json`. See +[package.json Validation](Package-Json-Validation.md) for the rules and the profiles. + +| Key | Default | Purpose | +| --- | --- | --- | +| `Enabled` | `false` | Adds `ValidatePackageJsonModule` to the pipeline. The standalone `package-validate` command always works, whether or not this is set | +| `Profile` | `Auto` | Derives the profile from `Build:ProjectType`, `Build:RunPack` and the file's own `private` flag. `DotNetNuGet`, `DotNetSolution`, `WebPackage` and `WebApp` declare it instead, which is the only way to catch a `private` flag that contradicts the repository | +| `DisabledRules` | `[]` | `PKG0nn` rule IDs to skip, e.g. `["PKG008"]`. Unknown IDs are ignored | +| `AdditionalRequiredFields` | `[]` | Extra top-level fields the repository requires, e.g. `["funding"]`; a missing or empty field fails `PKG014` | +| `TreatWarningsAsErrors` | `false` | Makes a warning-level finding fail the run, once the file is clean | + ## `NuGet` | Key | Default | Purpose | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index f13d190..a43b7c9 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -24,6 +24,7 @@ The same implementation is available three ways: - [Configuration Reference](Configuration-Reference.md) - [Pipeline Modules](Pipeline-Modules.md) - [Pack Validation](Pack-Validation.md) +- [package.json Validation](Package-Json-Validation.md) - [Release Flow](Release-Flow.md) - [Release Models](Release-Models.md) - [Local Development](Local-Development.md) diff --git a/docs/wiki/Local-Development.md b/docs/wiki/Local-Development.md index 3a2e248..9dc8123 100644 --- a/docs/wiki/Local-Development.md +++ b/docs/wiki/Local-Development.md @@ -68,6 +68,7 @@ the freshly built `.tools/local/Purview.Build`. | `just release-simulate ref version *args` | Explain the decision for a simulated ref and version | | `just release-matrix` | Run every eligibility scenario through the real CLI; non-zero on any mismatch | | `just config-matrix` | Run every config-resolution scenario through the real CLI | +| `just package-matrix` | Run every `package.json` validation scenario through the real CLI | | `just release-explain-golden` | Regenerate the `release-explain` JSON golden file and show the diff | `just release-matrix` and `just config-matrix` are the two commands to run before opening a pull @@ -79,6 +80,7 @@ fixture files drive the TUnit suites: - `src/tests/fixtures/eligibility-scenarios.json` - `src/tests/fixtures/config-resolution-scenarios.json` +- `src/tests/fixtures/package-json-scenarios.json` Adding a rule or a probe location means appending cases to those files, not writing test code. diff --git a/docs/wiki/Package-Json-Validation.md b/docs/wiki/Package-Json-Validation.md new file mode 100644 index 0000000..ba35618 --- /dev/null +++ b/docs/wiki/Package-Json-Validation.md @@ -0,0 +1,108 @@ +# package.json Validation + +Every consuming repository has a root `package.json`, even a .NET one: it is where the release +version lives. This check holds that file to the content a best-in-class package of the repository's +kind is expected to declare — a name, a version, a description, a licence, and the details a +consumer, a registry and a maintainer need. + +It is **opt-in in the pipeline and always available as a command**: + +| Invocation | Behaviour | +| --- | --- | +| `purview-build package-validate` | Runs the check on its own, runs no module, and exits `0` when it passes, `1` when it does not. Needs no configuration. | +| `purview-build package-validate --format=json` | The same check, as the JSON contract below, for `jq` or a workflow step. | +| `PackageJson:Enabled=true` | Adds `ValidatePackageJsonModule` to the pipeline, so a failing file fails the build. | + +Because it reads one file, it is the cheapest gate to add to a PR: it needs no build, no restore and +no network. + +## The profile decides which rules apply + +The rules are not one list. A published npm package, an application that must never be published, a +.NET library that ships NuGet packages and a .NET repository that packs nothing all need different +things from the same file. That is the **profile**: + +| Profile | Resolved when (with `Profile: Auto`) | What it means | +| --- | --- | --- | +| `DotNetNuGet` | `Build:ProjectType=DotNet` and `Build:RunPack=true` | A .NET repository that packs NuGet package(s). The file is not published to npm | +| `DotNetSolution` | `Build:ProjectType=DotNet` and `Build:RunPack=false` | A .NET repository that packs nothing | +| `WebPackage` | `Build:ProjectType=Web` and the file is not `private` | A Web repository whose file is published to npm | +| `WebApp` | `Build:ProjectType=Web` and the file is `private: true` | A Web application, which is never published | + +`PackageJson:Profile` overrides the derivation. That is the only way to express the two cases Auto +cannot: a published npm package that wrongly says `private: true`, and an application that has +forgotten to say it at all — Auto derives the profile *from* that flag, so the contradiction only +exists once a profile is declared. + +## Rules + +Rule IDs are permanent: never renumbered, never repurposed. Severity depends on the profile, which is +the whole point — the same defect is fatal for a published package and advisory for a private one. + +| Rule | Checks | Severity | +| --- | --- | --- | +| `PKG001` | `name` is present, and valid for npm (lowercase, url-safe, optional `@scope/`) | error when missing; error for `WebPackage`, warning elsewhere when npm would reject it | +| `PKG002` | `version` is present, parses as SemVer 2.0, is not the `0.0.0` placeholder, and is the version the pipeline resolved | error when missing or unparseable; error for `WebPackage`, warning elsewhere for a four-part version npm rejects (`Version:Strictness=NuGet` accepts it) | +| `PKG003` | `description` is present and says something | error when missing; warning when shorter than 10 characters or identical to the name | +| `PKG004` | `license` is an SPDX identifier or `SEE LICENSE IN ` | error when missing; warning for an unlisted identifier, or for `UNLICENSED` on a published package | +| `PKG005` | `repository` is an object with `type` and `url` | error when missing from a published file, warning elsewhere; warning for the shorthand string form or a missing field | +| `PKG006` | `author` is a string, or an object with `name` | error when missing from a published file or empty, warning elsewhere | +| `PKG007` | `homepage` or `bugs.url` is declared | warning | +| `PKG008` | `keywords` are declared | warning; skipped when the file is not published | +| `PKG009` | `private` agrees with what the repository does with the file | error for a private `WebPackage` or a publishable `WebApp`; warning for a `.NET` file that is not `private` | +| `PKG010` | `engines.node` declares the supported Node range | error for `WebPackage`, warning for `WebApp`, skipped for `.NET` | +| `PKG011` | one of `main`/`module`/`exports`/`types`/`bin` is declared | warning; `WebPackage` only | +| `PKG012` | the scripts this run drives are declared. The build command's script is required; the test, lint and format-check scripts are named | error for a missing build script, warning for the rest; skipped when the run drives none | +| `PKG013` | `files` declares the published tarball's contents | warning; `WebPackage` only | +| `PKG014` | every field named by `PackageJson:AdditionalRequiredFields` is present and non-empty | error; skipped when the list is empty | +| `PKG000` | the file could not be read or parsed | error — reported in the same shape as any other finding, never thrown | + +`PKG012` reads the script names out of `Build:WebBuildCommand`, `Build:WebTestCommand`, +`Build:WebLintCommand` and `Build:WebFormatCheckCommand`, so overriding a command moves the check +with it. + +## The JSON contract + +`--format=json` emits the same shape whether the file passed or failed, including for a file that +could not be parsed: + +```json +{ + "file": "/repo/package.json", + "profile": "WebPackage", + "succeeded": false, + "exitCode": 1, + "errorCount": 1, + "warningCount": 1, + "results": [ + { "ruleId": "PKG001", "outcome": "pass", "message": "Name '@contoso/core' is a valid npm package name." }, + { "ruleId": "PKG003", "outcome": "fail", "message": "The 'description' field is missing or empty. ..." } + ], + "errors": ["PKG003: The 'description' field is missing or empty. ..."], + "warnings": ["PKG008: No 'keywords' are declared, ..."] +} +``` + +`outcome` is one of `pass`, `warning`, `fail` or `skipped`. Every rule is reported, in ID order, +whether or not it applied — so a skipped rule is visibly skipped rather than missing. + +## Tuning + +| Key | Effect | +| --- | --- | +| `PackageJson:Enabled` | Adds the step to the pipeline. Off by default | +| `PackageJson:Profile` | `Auto` (default), `DotNetNuGet`, `DotNetSolution`, `WebPackage`, `WebApp` | +| `PackageJson:DisabledRules` | Rule IDs to skip, e.g. `["PKG008"]`. Unknown IDs are ignored | +| `PackageJson:AdditionalRequiredFields` | Extra top-level fields the repository requires, e.g. `["funding"]` | +| `PackageJson:TreatWarningsAsErrors` | Makes a warning fail the run, once the file is clean | + +A repository that has decided a rule does not apply should disable it by ID and say why, rather than +relaxing the check for everyone. A first run on an existing file is likely to report warnings from +`PKG007`, `PKG008` and `PKG013`: they are advisory precisely so that adopting the check does not +become a gate on day one. + +## See also + +- [Configuration Reference](Configuration-Reference.md) +- [Pipeline Modules](Pipeline-Modules.md) +- [Pack Validation](Pack-Validation.md) diff --git a/docs/wiki/Pipeline-Modules.md b/docs/wiki/Pipeline-Modules.md index 9b92d4f..ca0996f 100644 --- a/docs/wiki/Pipeline-Modules.md +++ b/docs/wiki/Pipeline-Modules.md @@ -41,6 +41,12 @@ Deliberately not category `Release`: it observes, and it must never decline a re Every failure path inside it is a warning. A misconfigured policy or an unreadable simulated tag list must not fail a build that would otherwise succeed. When `Release:Context:*` supplied a simulated context, the report says so. +## ValidatePackageJsonModule + +Category `Build`. Depends on `VersionModule`. Skip condition: skipped unless `PackageJson:Enabled` is true — the check is opt-in, because the file already exists in every repository. + +Validates the repository root `package.json` against the rules for the repository's profile (`.NET` with or without NuGet packages, published npm package, or web application), logs every rule's outcome in ID order, and fails the run when any rule reports an error. `PackageJson:Profile` overrides the derived profile; `PackageJson:DisabledRules`, `PackageJson:AdditionalRequiredFields` and `PackageJson:TreatWarningsAsErrors` tune the check. The same validation is available without the pipeline as `purview-build package-validate`. See [package.json Validation](Package-Json-Validation.md). + ## RestoreModule Runs `Build:WebInstallCommand` (default `bun install`) when `Build:ProjectType` is `Web`, otherwise `dotnet restore` against `Build:Solution`. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 6143969..7c16658 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -4,6 +4,7 @@ - [Configuration Reference](Configuration-Reference.md) - [Pipeline Modules](Pipeline-Modules.md) - [Pack Validation](Pack-Validation.md) +- [package.json Validation](Package-Json-Validation.md) - [Release Flow](Release-Flow.md) - [Release Models](Release-Models.md) - [Local Development](Local-Development.md) diff --git a/package.json b/package.json index 633b9d4..dda529b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "build", - "version": "0.9.0", + "version": "0.9.1", "description": "Shared build/test/release pipeline for the purview-dev organisation, packaged as a pinned .NET tool with a GitHub composite action and reusable workflows.", "license": "MIT", "author": { @@ -8,6 +8,13 @@ "url": "https://kieronlanning.dev/" }, "private": true, + "keywords": [ + "build", + "pipeline", + "modular-pipelines", + "dotnet-tool", + "ci-cd" + ], "homepage": "https://purview.dev/projects/build/", "bugs": { "url": "https://github.com/purview-dev/build/issues" diff --git a/purview-build.schema.json b/purview-build.schema.json index 9b1af0b..7c9f9e9 100644 --- a/purview-build.schema.json +++ b/purview-build.schema.json @@ -205,6 +205,54 @@ "^\\$": true } }, + "PackageJson": { + "type": "object", + "properties": { + "Enabled": { + "type": "boolean", + "description": "When true, ValidatePackageJsonModule runs as part of the pipeline.", + "default": false + }, + "Profile": { + "type": "string", + "enum": [ + "Auto", + "DotNetNuGet", + "DotNetSolution", + "WebPackage", + "WebApp" + ], + "description": "The registry profile whose rules apply, or Auto to derive it from the project type and the file itself.", + "default": "Auto" + }, + "DisabledRules": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Rule IDs to skip, e.g. [\u0022PKG008\u0022] for a repository that deliberately declares no keywords. Unknown IDs are ignored.", + "default": [] + }, + "AdditionalRequiredFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Extra top-level fields the repository requires on top of the built-in rules, e.g. [\u0022funding\u0022]. Any that is missing or empty is reported by PKG014.", + "default": [] + }, + "TreatWarningsAsErrors": { + "type": "boolean", + "description": "When true, a warning-level finding fails the run exactly as an error-level one does. Useful once the file is clean, to stop it drifting back.", + "default": false + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + }, + "description": "Opt-in validation of the repository root package.json against the content a best-in-class package of the repository\u0027s kind is expected to declare." + }, "NuGet": { "type": "object", "properties": { diff --git a/skills/purview-build-adoption/SKILL.md b/skills/purview-build-adoption/SKILL.md index ff5ce0e..5aaef5b 100644 --- a/skills/purview-build-adoption/SKILL.md +++ b/skills/purview-build-adoption/SKILL.md @@ -207,6 +207,7 @@ Before running anything destructive, two commands tell you what *would* happen: ```shell dotnet purview-build --help # usage, probe order, and the resolved config path dotnet purview-build release-explain # the release decision, without running a module +dotnet purview-build package-validate # check the root package.json: exit 0 when it passes ``` ## Releasing a consuming repository @@ -230,4 +231,5 @@ same name first. `purview-build-configuration` for the full `purview-build.json` reference and the secrets, `purview-build-pipeline` for the modules and their order, `purview-build-pack-validation` for the -package rules, and `purview-build-release-eligibility` for the `REL0nn` rules and the three models. +package rules, `purview-build-package-json` for the root `package.json` check, and +`purview-build-release-eligibility` for the `REL0nn` rules and the three models. diff --git a/skills/purview-build-configuration/SKILL.md b/skills/purview-build-configuration/SKILL.md index d5536c0..0309c04 100644 --- a/skills/purview-build-configuration/SKILL.md +++ b/skills/purview-build-configuration/SKILL.md @@ -128,6 +128,20 @@ drop a trailing `.0` revision and desynchronise the tag from the declared versio `Release:Context` (`Ref`, `ExistingTags`, `PublishedVersions`, for simulated evaluation) are covered by the `purview-build-release-eligibility` skill. +## `PackageJson` + +Opt-in validation of the repository-root `package.json`, and the settings that tune it. + +| Key | Default | Purpose | +| --- | --- | --- | +| `Enabled` | `false` | adds `ValidatePackageJsonModule` to the pipeline. The `package-validate` command works regardless | +| `Profile` | `Auto` | `Auto` derives it from `Build:ProjectType`, `Build:RunPack` and the file's own `private` flag. `DotNetNuGet`, `DotNetSolution`, `WebPackage` and `WebApp` declare it instead — the only way to catch a `private` flag that contradicts the repository | +| `DisabledRules` | `[]` | `PKG0nn` IDs to skip, e.g. `["PKG008"]`. Unknown IDs are ignored | +| `AdditionalRequiredFields` | `[]` | extra top-level fields this repository requires, e.g. `["funding"]`; a missing one fails `PKG014` | +| `TreatWarningsAsErrors` | `false` | fail the run on a warning, once the file is clean | + +The `purview-build-package-json` skill has the rules, the profiles, and the JSON contract. + ## Secrets Never committed; supplied at runtime. @@ -175,11 +189,12 @@ fails immediately instead of skipping the publish or release module after the bu | `-v`, `--version` | print the tool version and exit | | `-h`, `--help`, `-?` | usage, options, the probe order, and the **resolved configuration path** (or "none found") | | `release-explain` | print the release decision and exit, running no module. `--format=json` emits the stable contract the release action consumes | +| `package-validate` | validate the root `package.json` and exit, running no module: `0` when it passes, `1` when it does not. `--format=json` emits the report | `--help` printing the resolved path is the fastest answer to "which config is it actually reading?". ## Related skills `purview-build-adoption` for wiring it up, `purview-build-pipeline` for what each module does, -`purview-build-pack-validation` for the content rules, and `purview-build-release-eligibility` for -the `REL0nn` policies. +`purview-build-pack-validation` for the content rules, `purview-build-package-json` for the +`package.json` rules, and `purview-build-release-eligibility` for the `REL0nn` policies. diff --git a/skills/purview-build-package-json/SKILL.md b/skills/purview-build-package-json/SKILL.md new file mode 100644 index 0000000..00c8860 --- /dev/null +++ b/skills/purview-build-package-json/SKILL.md @@ -0,0 +1,133 @@ +--- +name: purview-build-package-json +description: >- + Use when a Purview.Build package-json validation fails, or when bringing an existing root + package.json up to standard - the four profiles (DotNetNuGet, DotNetSolution, WebPackage, WebApp), + the PKG0nn rules and their per-profile severity, the package-validate command, and the JSON report. +metadata: + product: Purview.Build + repository: purview-dev/build +--- + +# package.json validation + +Every consuming repository has a root `package.json`, even a .NET one: it is where the release version +lives. `ValidatePackageJsonModule` checks it against what a best-in-class package of the repository's +kind should declare, and **fails the run on any error-level finding** — this one is a gate, unlike +the release-eligibility report. + +Two ways in: + +| Invocation | Behaviour | +| --- | --- | +| `purview-build package-validate` | runs on its own, builds **no pipeline**, exits `0` on pass / `1` on fail | +| `PackageJson:Enabled=true` | adds the module to the pipeline, gated on `Version` | + +`package-validate` needs no configuration and no build, so it is the cheapest PR gate: try it before +changing any settings. + +## The profile is the whole design + +One rule list cannot serve four kinds of repository. The **profile** decides which rules apply and, for +several of them, whether a miss is an error or a warning: + +| Profile | With `Profile: Auto` | Meaning | +| --- | --- | --- | +| `DotNetNuGet` | `ProjectType=DotNet`, `RunPack=true` | .NET repo that packs NuGet package(s); the file is **not** published to npm | +| `DotNetSolution` | `ProjectType=DotNet`, `RunPack=false` | .NET repo that packs nothing | +| `WebPackage` | `ProjectType=Web`, file is not `private` | the file **is** published to npm | +| `WebApp` | `ProjectType=Web`, file is `private: true` | a web application, never published | + +`Auto` derives the profile *from* `private`, so it can never see a contradiction between the two. The +only way to catch those is to declare `PackageJson:Profile` explicitly: + +- a published npm package that wrongly says `private: true` → `WebPackage` + `private` → `PKG009` error; +- an application that forgot `private` entirely → `WebApp` without it → `PKG009` error. + +A `.NET` file that is not `private` is only a warning: it is not an npm package, but a stray +`npm publish` at the root would still try to publish it. + +## The rules + +Rule IDs are permanent. Severity is stated per profile. + +| Rule | Checks | Severity | +| --- | --- | --- | +| `PKG001` | `name` present, and a valid npm name (lowercase, url-safe, optional `@scope/`) | error when missing; error for `WebPackage`, warning elsewhere when npm would reject it | +| `PKG002` | `version` present, SemVer 2.0, not the `0.0.0` placeholder | error when missing/unparseable; error for `WebPackage`, warning elsewhere for a four-part version | +| `PKG003` | `description` present and substantive | error when missing; warning when under 10 characters or equal to the name | +| `PKG004` | `license` is an SPDX identifier or `SEE LICENSE IN ` | error when missing; warning for an unlisted identifier or `UNLICENSED` on a published package | +| `PKG005` | `repository` object with `type` and `url` | error when missing from a published file, warning elsewhere; warning for the shorthand string form | +| `PKG006` | `author` is a string, or an object with `name` | error when missing from a published file or empty, warning elsewhere | +| `PKG007` | `homepage` or `bugs.url` declared | warning | +| `PKG008` | `keywords` declared | warning; **skipped** when the file is not published | +| `PKG009` | `private` agrees with how the file is used | error for a private `WebPackage` or a publishable `WebApp`; warning for a `.NET` file without it | +| `PKG010` | `engines.node` range declared | error for `WebPackage`, warning for `WebApp`, skipped for `.NET` | +| `PKG011` | one of `main`/`module`/`exports`/`types`/`bin` declared | warning; `WebPackage` only | +| `PKG012` | the scripts the run drives are declared | error for the **build** script, warning for test/lint/format-check; skipped when the run drives none | +| `PKG013` | `files` declares the published contents | warning; `WebPackage` only | +| `PKG014` | each `PackageJson:AdditionalRequiredFields` entry is present and non-empty | error; skipped when the list is empty | +| `PKG000` | the file could not be read or parsed | error — never thrown, always reported | + +Two behaviours worth remembering: + +- **Every rule is reported, in ID order**, including `skipped` ones. One missing field never hides the + rest of the findings, and a rule that did not apply is visibly absent rather than silently passing. +- **`PKG002` is deliberately profile-dependent.** `Version:Strictness` defaults to `NuGet`, which + accepts four-part versions such as `13.5.3.10` — two consuming repositories ship them. npm accepts + only SemVer 2.0, so the same value is fatal for a `WebPackage` and merely a warning for a `.NET` repo. + Do not "fix" the warning by tightening `Version:Strictness`, which would stop those repositories + releasing. + +`PKG012` reads its script names from `Build:WebBuildCommand`, `Build:WebTestCommand`, +`Build:WebLintCommand` and `Build:WebFormatCheckCommand`, so overriding a command moves the check with +it. + +## The report + +`--format=json` emits the same shape whether it passed or failed, including when the file could not be +parsed, and `package-validate` exits `1` when `succeeded` is false: + +```json +{ + "file": "/repo/package.json", + "profile": "WebPackage", + "succeeded": false, + "exitCode": 1, + "errorCount": 1, + "warningCount": 1, + "results": [ + { "ruleId": "PKG001", "outcome": "pass", "message": "Name '@contoso/core' is a valid npm package name." }, + { "ruleId": "PKG003", "outcome": "fail", "message": "The 'description' field is missing or empty. ..." } + ], + "errors": ["PKG003: The 'description' field is missing or empty. ..."], + "warnings": ["PKG008: No 'keywords' are declared, ..."] +} +``` + +Read `errors`/`warnings` for a summary, `results` for which rules ran and which were skipped. +`PKG000` in `results` means the file was missing or malformed — everything else was never evaluated. + +## Tuning, and how to adopt it + +| Key | Effect | +| --- | --- | +| `PackageJson:Enabled` | adds the step to the pipeline; off by default | +| `PackageJson:Profile` | `Auto` (default), `DotNetNuGet`, `DotNetSolution`, `WebPackage`, `WebApp` | +| `PackageJson:DisabledRules` | `PKG0nn` IDs to skip, e.g. `["PKG008"]`. Unknown IDs are ignored | +| `PackageJson:AdditionalRequiredFields` | extra top-level fields this repository requires, e.g. `["funding"]` | +| `PackageJson:TreatWarningsAsErrors` | fail on a warning too, once the file is clean | + +Starting from `Auto` on an existing repository usually reports warnings from `PKG007`, `PKG008` and +`PKG013` — that is intended, so adopting the check is not an instant gate. Fix them, or disable a rule +by ID and say why; do not relax a rule for every repository. + +If a rule fires and looks wrong, the finding says which field it read and what it expected. A warning +about a **four-part version** on a `.NET` repository is expected and is not a reason to tighten +`Version:Strictness`. + +## Related skills + +`purview-build-configuration` for the `PackageJson` keys and the CLI verbs, `purview-build-pipeline` +for where `ValidatePackageJson` sits in the graph, and `purview-build-release-eligibility` for the +`REL0nn` version and ref policies, which are separate from `PKG0nn`. diff --git a/skills/purview-build-pipeline/SKILL.md b/skills/purview-build-pipeline/SKILL.md index bc98a4d..8260c27 100644 --- a/skills/purview-build-pipeline/SKILL.md +++ b/skills/purview-build-pipeline/SKILL.md @@ -17,12 +17,12 @@ define ordering, and `ModuleConfiguration` skip conditions gate opt-in behaviour `Build` and `Release`. ```text -CleanArtifacts → { Version, Restore → Build → Test, Restore → Lint } → Pack → ValidatePack → Publish → GitHub release +CleanArtifacts → { Version → ValidatePackageJson, Restore → Build → Test, Restore → Lint } → Pack → ValidatePack → Publish → GitHub release └→ ReportEligibility ``` -**`Lint` and `ReportEligibility` are gates and reports, not inputs.** Nothing depends on either, so -neither feeds `Pack`. +**`Lint`, `ReportEligibility` and `ValidatePackageJson` are gates and reports, not inputs.** Nothing +depends on any of them, so none feeds `Pack`. Every module logs a line when it starts (`Running BuildModule...`) before its command output, so a long run still reports progress in CI logs where the live progress display is disabled. @@ -74,6 +74,26 @@ Every failure path inside it is a **warning**. A misconfigured policy or an unre list must not fail a build that would otherwise succeed. When `Release:Context:*` supplied a simulated context, the report says so. +## ValidatePackageJson + +Category `Build`. Depends on `Version` — the version it resolved is the version the file must declare. +*Skipped unless* `PackageJson:Enabled` is true: the check is opt-in, because a root `package.json` +already exists in every repository. + +Validates that file against the rules for the repository's **profile**, logs every rule's outcome in +ID order (`pass`/`warning`/`fail`/`skipped`), and **fails the run on any error-level finding** — +unlike `ReportEligibility`, this one is a gate. + +The profile is derived from `Build:ProjectType`, `Build:RunPack` and the file's own `private` flag: +`DotNetNuGet`, `DotNetSolution`, `WebPackage` or `WebApp`. `PackageJson:Profile` overrides it, which +is the only way to catch a `private` flag that contradicts the repository — Auto derives the profile +*from* that flag. Severity is profile-dependent: a missing `license` fails a published package, and a +four-part version fails a `WebPackage` (npm requires SemVer 2.0) while merely warning elsewhere +(`Version:Strictness=NuGet` accepts it). + +The same validation is reachable without the pipeline as `purview-build package-validate`, which +builds no graph at all and exits 0/1. The `purview-build-package-json` skill has the rules. + ## Restore `Build:WebInstallCommand` (default `bun install`) when `Build:ProjectType` is `Web`; otherwise @@ -199,5 +219,6 @@ it but does not skip an existing tag itself. ## Related skills `purview-build-configuration` for every setting named above, `purview-build-pack-validation` for -what `ValidatePack` actually checks, and `purview-build-release-eligibility` for the verdict +what `ValidatePack` actually checks, `purview-build-package-json` for what `ValidatePackageJson` +checks, and `purview-build-release-eligibility` for the verdict `ReportEligibility` prints. diff --git a/src/src/Build/Helpers/BuildPipeline.cs b/src/src/Build/Helpers/BuildPipeline.cs index 73be5a3..2f9cf1d 100644 --- a/src/src/Build/Helpers/BuildPipeline.cs +++ b/src/src/Build/Helpers/BuildPipeline.cs @@ -94,6 +94,9 @@ static void BindSettings(PipelineBuilder builder) builder.Services.Configure( builder.Configuration.GetSection(PackValidationSettings.SectionName) ); + builder.Services.Configure( + builder.Configuration.GetSection(PackageJsonValidationSettings.SectionName) + ); builder.Services.Configure( builder.Configuration.GetSection(PublishLocalNuGetSettings.SectionName) ); @@ -122,6 +125,7 @@ static void AddModules(PipelineBuilder builder) => .AddModule() .AddModule() .AddModule() + .AddModule() .AddModule() .AddModule() .AddModule() diff --git a/src/src/Build/Helpers/ToolCommandLine.cs b/src/src/Build/Helpers/ToolCommandLine.cs index d006879..3cab31c 100644 --- a/src/src/Build/Helpers/ToolCommandLine.cs +++ b/src/src/Build/Helpers/ToolCommandLine.cs @@ -16,6 +16,9 @@ enum ToolCommand /// Explain the release decision and exit, without running any module. ReleaseExplain, + + /// Validate the root package.json and exit, without running any module. + PackageValidate, } /// @@ -49,6 +52,8 @@ string[] PipelineArguments { const string ReleaseExplainVerb = "release-explain"; + const string PackageValidateVerb = "package-validate"; + static readonly string[] ConfigFlags = ["--config", "-c"]; public static ToolCommandLine Parse(string[] args) @@ -73,11 +78,23 @@ public static ToolCommandLine Parse(string[] args) { var arg = args[index]; - if (string.Equals(arg, ReleaseExplainVerb, StringComparison.OrdinalIgnoreCase)) + if (TryReadVerb(arg, ReleaseExplainVerb, command, ToolCommand.ReleaseExplain, out var explained)) { - if (command == ToolCommand.RunPipeline) - command = ToolCommand.ReleaseExplain; + command = explained; + continue; + } + if ( + TryReadVerb( + arg, + PackageValidateVerb, + command, + ToolCommand.PackageValidate, + out var validating + ) + ) + { + command = validating; continue; } @@ -107,6 +124,31 @@ public static ToolCommandLine Parse(string[] args) return new(command, configPath, userConfig, format, [.. remaining]); } + /// + /// Consumes one of the tool's own verbs, which is never a configuration override: recognising it + /// here keeps the bare word away from the configuration command-line provider, which accepts only + /// --key=value forms. An informational command (--help/--version) wins over + /// any verb. + /// + static bool TryReadVerb( + string arg, + string verb, + ToolCommand current, + ToolCommand target, + out ToolCommand command + ) + { + command = current; + + if (!string.Equals(arg, verb, StringComparison.OrdinalIgnoreCase)) + return false; + + if (current == ToolCommand.RunPipeline) + command = target; + + return true; + } + /// /// Reads --flag=value or --flag value, advancing past the value in the latter case. /// diff --git a/src/src/Build/Helpers/ToolInfo.cs b/src/src/Build/Helpers/ToolInfo.cs index 58cb88c..1c1e118 100644 --- a/src/src/Build/Helpers/ToolInfo.cs +++ b/src/src/Build/Helpers/ToolInfo.cs @@ -34,10 +34,16 @@ public static string BuildHelpText(ConfigResolution? resolution) => Usage: purview-build [configuration overrides] purview-build release-explain [--format=json] + purview-build package-validate [--format=json] Commands: release-explain Explain the release decision and exit, without running any module. --format=json emits the stable contract the reusable workflow consumes. + package-validate Validate the repository root package.json against the rules for the + repository's kind (.NET with or without NuGet packages, npm package, or + web application), and exit: 0 when it passes, 1 when it does not. + --format=json emits a report for a workflow or jq. Needs no + configuration; PackageJson:Enabled only adds it to the pipeline. Options: -v, --version Print the {{Name}} version and exit. @@ -64,9 +70,10 @@ Relative paths in the configuration file (Build:Solution, Build:TestRoot, Build: Build:WebBuildOutput, PackValidation globs) always resolve against the repository root, wherever the configuration file itself was found. - Modules run in dependency order: CleanArtifacts, Version, ReportEligibility, Restore, Build, Lint, - RunTests, Pack, ValidatePack, PublishNuGet, PublishLocalNuGet, CreateGitHubRelease. Each logs a line - when it starts and when it finishes; a failing run reports the failed module's error and exits 1. + Modules run in dependency order: CleanArtifacts, Version, ReportEligibility, ValidatePackageJson, + Restore, Build, Lint, RunTests, Pack, ValidatePack, PublishNuGet, PublishLocalNuGet, + CreateGitHubRelease. Each logs a line when it starts and when it finishes; a failing run reports + the failed module's error and exits 1. Required dependencies of the configured capabilities (Build:Solution, Build:CSharpierPath, PublishLocalNuGet:LocalFeedPath, NuGet credentials, GitHub token and repository id) are checked diff --git a/src/src/Build/Modules/ValidatePackageJsonModule.cs b/src/src/Build/Modules/ValidatePackageJsonModule.cs new file mode 100644 index 0000000..bbe0e55 --- /dev/null +++ b/src/src/Build/Modules/ValidatePackageJsonModule.cs @@ -0,0 +1,87 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using Purview.Build.PackageJson; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Purview.Build.Modules; + +/// +/// Validates the repository root package.json against the content a best-in-class package of +/// the repository's kind is expected to declare. +/// +/// +/// Opt-in through PackageJson:Enabled: the file already exists everywhere, so holding it to a +/// standard is a deliberate decision. The same checks are always available on their own through +/// purview-build package-validate, which needs no configuration at all. +/// +[ModuleCategory("Build")] +[DependsOn] +public sealed class ValidatePackageJsonModule( + IOptions buildSettings, + IOptions versionSettings, + IOptions packageJsonSettings +) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + packageJsonSettings.Value.Enabled + ? SkipDecision.DoNotSkip + : SkipDecision.Skip( + "package.json validation is opt-in. Set PackageJson__Enabled=true to enable it, " + + "or run 'purview-build package-validate' on its own." + ) + ) + .Build(); + + protected override async Task ExecuteAsync( + [NotNull] IModuleContext context, + CancellationToken cancellationToken + ) + { + ModuleProgress.Starting(context, nameof(ValidatePackageJsonModule)); + + var versionResult = await context.GetModule(); + var resolvedVersion = versionResult.ValueOrDefault?.RawVersion; + + var report = PackageJsonValidationRunner.Run( + packageJsonSettings.Value, + buildSettings.Value, + versionSettings.Value, + PathHelpers.FindRepositoryRoot(), + resolvedVersion + ); + + foreach (var result in report.Results) + { + context.Logger.LogInformation( + " {Outcome,-7} {RuleId} {Message}", + result.Outcome, + result.RuleId, + result.Message + ); + } + + context.Summary.KeyValue("package.json", "Profile", report.Profile); + context.Summary.KeyValue( + "package.json", + "Errors", + report.ErrorCount.ToString(CultureInfo.InvariantCulture) + ); + context.Summary.KeyValue( + "package.json", + "Warnings", + report.WarningCount.ToString(CultureInfo.InvariantCulture) + ); + + if (!report.Succeeded) + throw new InvalidOperationException(PackageJsonValidationReportText.Render(report)); + + return report; + } +} diff --git a/src/src/Build/PackageJson/IPackageJsonRule.cs b/src/src/Build/PackageJson/IPackageJsonRule.cs new file mode 100644 index 0000000..c85a35a --- /dev/null +++ b/src/src/Build/PackageJson/IPackageJsonRule.cs @@ -0,0 +1,18 @@ +namespace Purview.Build.PackageJson; + +/// +/// One package.json content rule. +/// +/// +/// Rule IDs are permanent identifiers: never renumbered, never repurposed. +/// +interface IPackageJsonRule +{ + /// The rule's permanent PKG0nn identifier. + string Id { get; } + + /// One-line description, used by the report and the documentation. + string Description { get; } + + PackageJsonRuleResult Evaluate(PackageJsonValidationContext context); +} diff --git a/src/src/Build/PackageJson/NpmNames.cs b/src/src/Build/PackageJson/NpmNames.cs new file mode 100644 index 0000000..5f55022 --- /dev/null +++ b/src/src/Build/PackageJson/NpmNames.cs @@ -0,0 +1,87 @@ +namespace Purview.Build.PackageJson; + +/// +/// npm's rules for a package name, as the registry enforces them. +/// +static class NpmNames +{ + const int MaxLength = 214; + + /// + /// Describes why would be rejected by npm, or when + /// it is acceptable. + /// + public static string? DescribeProblem(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return "it is empty"; + + if (name.Length > MaxLength) + return $"it is longer than {MaxLength} characters"; + + if (name.Any(char.IsUpper)) + return "npm package names must be lowercase"; + + if (name.Trim() != name) + return "leading or trailing whitespace is not allowed"; + + if (name is "node_modules" or "favicon.ico") + return "it is a name npm reserves"; + + var start = 0; + + if (name[0] == '@') + { + var separator = FindSeparator(name); + if (separator < 0) + return "a scoped name must be written '@scope/name'"; + + var scope = name[1..separator]; + if (scope.Length == 0) + return "the scope is empty"; + + if (DescribePartProblem(scope) is { } scopeProblem) + return $"the scope '{scope}' {scopeProblem}"; + + start = separator + 1; + } + else if (FindSeparator(name) >= 0) + { + return "only a scoped name ('@scope/name') may contain '/'"; + } + + var leaf = name[start..]; + if (leaf.Length == 0) + return "the name after the scope is empty"; + + return DescribePartProblem(leaf); + } + + static int FindSeparator(string value) + { + for (var index = 0; index < value.Length; index++) + { + if (value[index] == '/') + return index; + } + + return -1; + } + + static string? DescribePartProblem(string part) + { + if (part.Length > 0 && part[0] is '.' or '_') + return "must not start with '.' or '_'"; + + foreach (var character in part) + { + if (!IsAllowed(character)) + return $"contains '{character}', which npm does not allow"; + } + + return null; + } + + static bool IsAllowed(char character) => + character is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '.' or '_'; +} diff --git a/src/src/Build/PackageJson/PackageJsonDocument.cs b/src/src/Build/PackageJson/PackageJsonDocument.cs new file mode 100644 index 0000000..3dbe22a --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonDocument.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Purview.Build.PackageJson; + +/// +/// A parsed root package.json, read tolerantly. +/// +/// +/// Only parsing can fail. Beyond that every accessor answers "absent" rather than throwing, so a +/// rule — not an exception — reports a missing or mistyped field, and one bad field never hides the +/// rest of the findings. +/// +sealed class PackageJsonDocument +{ + PackageJsonDocument(JsonObject root, string path) + { + Root = root; + Path = path; + } + + /// The parsed document. + public JsonObject Root { get; } + + /// Where the document was read from, for diagnostics. + public string Path { get; } + + /// + /// Reads and parses a package.json, capturing either failure as a message so a caller can + /// report it as a finding instead of throwing. + /// + public static bool TryLoad(string path, out PackageJsonDocument? document, out string? error) + { + document = null; + + if (!File.Exists(path)) + { + error = $"No package.json was found at '{path}'."; + return false; + } + + return TryParse(File.ReadAllText(path), path, out document, out error); + } + + /// + /// Parses as a package.json, capturing either failure as a + /// message. + /// + public static bool TryParse( + string json, + string path, + out PackageJsonDocument? document, + out string? error + ) + { + document = null; + error = null; + + try + { + if (JsonNode.Parse(json) is not JsonObject root) + { + error = $"'{path}' is not a JSON object."; + return false; + } + + document = new(root, path); + return true; + } + catch (JsonException exception) + { + error = $"'{path}' is not valid JSON: {exception.Message}"; + return false; + } + } + + /// + /// Parses a document that is known to be valid, for callers that own their input (the scenario + /// fixtures). + /// + public static PackageJsonDocument FromJson(string json, string path = "package.json") + { + if (!TryParse(json, path, out var document, out var error)) + throw new InvalidOperationException(error); + + return document!; + } + + /// Whether the document declares "private": true. + public bool IsPrivate => GetBool("private"); + + public bool Has(string name) => Root.ContainsKey(name); + + public bool HasNonEmpty(string name) => + Root.TryGetPropertyValue(name, out var node) && !IsBlank(node); + + public string? GetString(string name) => + Root.TryGetPropertyValue(name, out var node) + && node is JsonValue value + && value.TryGetValue(out var text) + ? text + : null; + + public bool GetBool(string name) => + Root.TryGetPropertyValue(name, out var node) + && node is JsonValue value + && value.TryGetValue(out var flag) + && flag; + + public JsonObject? GetObject(string name) => + Root.TryGetPropertyValue(name, out var node) && node is JsonObject obj ? obj : null; + + public string? GetNestedString(string name, string property) => + GetObject(name) is { } obj + && obj.TryGetPropertyValue(property, out var node) + && node is JsonValue value + && value.TryGetValue(out var text) + ? text + : null; + + /// The non-empty string members of an array-valued field, or empty when it is absent or of another shape. + public IReadOnlyList GetStrings(string name) + { + if (Root.TryGetPropertyValue(name, out var node) is false || node is not JsonArray array) + return []; + + List values = [with(array.Count)]; + foreach (var item in array) + { + if ( + item is JsonValue value + && value.TryGetValue(out var text) + && !string.IsNullOrWhiteSpace(text) + ) + { + values.Add(text); + } + } + + return values; + } + + /// + /// True when a node carries no value: absent, null, or a whitespace-only string or empty + /// array. + /// + public static bool IsBlank(JsonNode? node) => + node switch + { + null => true, + JsonArray array => array.Count == 0, + JsonValue value when value.TryGetValue(out var text) => + string.IsNullOrWhiteSpace(text), + _ => false, + }; +} diff --git a/src/src/Build/PackageJson/PackageJsonProfiles.cs b/src/src/Build/PackageJson/PackageJsonProfiles.cs new file mode 100644 index 0000000..f9141c6 --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonProfiles.cs @@ -0,0 +1,44 @@ +namespace Purview.Build.PackageJson; + +/// +/// Resolves the effective , so which rules apply is a property of +/// the repository rather than something the tool guesses. +/// +static class PackageJsonProfiles +{ + /// + /// Resolves the profile: an explicit configuration wins, otherwise it is derived from the project + /// type, whether the repository packs NuGet packages, and the file's own private field. + /// + public static PackageJsonProfile Resolve( + PackageJsonProfile configured, + ProjectType projectType, + bool packsNuGetPackages, + bool isPrivate + ) => + configured switch + { + PackageJsonProfile.Auto when projectType == ProjectType.Web => + isPrivate ? PackageJsonProfile.WebApp : PackageJsonProfile.WebPackage, + PackageJsonProfile.Auto => + packsNuGetPackages + ? PackageJsonProfile.DotNetNuGet + : PackageJsonProfile.DotNetSolution, + _ => configured, + }; + + /// + /// True for profiles whose package.json is published to a package registry, where metadata + /// completeness stops being advisory. + /// + public static bool Publishes(PackageJsonProfile profile) => + profile is PackageJsonProfile.DotNetNuGet or PackageJsonProfile.WebPackage; + + /// + /// True for the npm world, where npm's own naming and engine rules apply. + /// + public static bool IsWeb(PackageJsonProfile profile) => + profile is PackageJsonProfile.WebPackage or PackageJsonProfile.WebApp; + + public static string Describe(PackageJsonProfile profile) => profile.ToString(); +} diff --git a/src/src/Build/PackageJson/PackageJsonRuleResult.cs b/src/src/Build/PackageJson/PackageJsonRuleResult.cs new file mode 100644 index 0000000..b4d3b63 --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonRuleResult.cs @@ -0,0 +1,35 @@ +namespace Purview.Build.PackageJson; + +/// What one rule concluded. +public enum PackageJsonRuleOutcome +{ + /// The requirement is met. + Pass, + + /// The requirement is not met, but the run does not fail on it. + Warning, + + /// The requirement is not met and the run fails. + Fail, + + /// The rule does not apply to this repository's profile, or was disabled. + Skipped, +} + +/// +/// The outcome of one rule, in evaluation order. +/// +sealed record PackageJsonRuleResult(string RuleId, PackageJsonRuleOutcome Outcome, string Message) +{ + public static PackageJsonRuleResult Pass(string ruleId, string message) => + new(ruleId, PackageJsonRuleOutcome.Pass, message); + + public static PackageJsonRuleResult Warning(string ruleId, string message) => + new(ruleId, PackageJsonRuleOutcome.Warning, message); + + public static PackageJsonRuleResult Fail(string ruleId, string message) => + new(ruleId, PackageJsonRuleOutcome.Fail, message); + + public static PackageJsonRuleResult Skipped(string ruleId, string message) => + new(ruleId, PackageJsonRuleOutcome.Skipped, message); +} diff --git a/src/src/Build/PackageJson/PackageJsonRules.cs b/src/src/Build/PackageJson/PackageJsonRules.cs new file mode 100644 index 0000000..0b43e0a --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonRules.cs @@ -0,0 +1,46 @@ +using Purview.Build.PackageJson.Rules; + +namespace Purview.Build.PackageJson; + +/// +/// The rule registry. Adding a rule means adding one implementation and one entry here. +/// +static class PackageJsonRules +{ + /// + /// The rule ID reported when the file could not be read at all. Not a rule: it exists so a + /// missing or malformed file is reported in the same shape as any other finding. + /// + public const string ReadFailureRuleId = "PKG000"; + + /// + /// Every rule, ordered by ID. Evaluation order is ID order, so the registry's order is the + /// documented order. + /// + public static IReadOnlyList All { get; } = + [ + new Pkg001NameRule(), + new Pkg002VersionRule(), + new Pkg003DescriptionRule(), + new Pkg004LicenseRule(), + new Pkg005RepositoryRule(), + new Pkg006AuthorRule(), + new Pkg007LinksRule(), + new Pkg008KeywordsRule(), + new Pkg009PrivateIntentRule(), + new Pkg010EnginesRule(), + new Pkg011EntryPointsRule(), + new Pkg012ScriptsRule(), + new Pkg013FilesRule(), + new Pkg014AdditionalRequiredFieldsRule(), + ]; + + public static IReadOnlyList KnownIds { get; } = + [ReadFailureRuleId, .. All.Select(rule => rule.Id)]; + + public static bool IsKnown(string ruleId) => + KnownIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase); + + public static IPackageJsonRule ById(string ruleId) => + All.First(rule => string.Equals(rule.Id, ruleId, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/src/Build/PackageJson/PackageJsonValidationContext.cs b/src/src/Build/PackageJson/PackageJsonValidationContext.cs new file mode 100644 index 0000000..aff390f --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonValidationContext.cs @@ -0,0 +1,15 @@ +namespace Purview.Build.PackageJson; + +/// +/// Everything a rule needs to judge one package.json: the document, the profile whose rules +/// apply, and the run-specific inputs a rule cannot infer from the file alone. +/// +sealed record PackageJsonValidationContext( + PackageJsonDocument Document, + PackageJsonProfile Profile, + VersionStrictness Strictness, + string? ResolvedVersion, + IReadOnlyList RequiredScripts, + IReadOnlyList RecommendedScripts, + IReadOnlyList AdditionalRequiredFields +); diff --git a/src/src/Build/PackageJson/PackageJsonValidationReport.cs b/src/src/Build/PackageJson/PackageJsonValidationReport.cs new file mode 100644 index 0000000..4e4d56c --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonValidationReport.cs @@ -0,0 +1,175 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Purview.Build.Release; + +namespace Purview.Build.PackageJson; + +/// +/// Everything a package.json validation run found, in one shape rendered either for a human or +/// as the JSON contract package-validate --format=json emits. +/// +/// +/// The JSON property names are a contract: a caller or workflow parses succeeded, +/// exitCode, profile and the per-rule results. Renaming or removing a property is +/// a breaking change, which is why the shape is pinned by the test suite. +/// +public sealed record PackageJsonValidationReport +{ + [JsonPropertyName("file")] + public string File { get; init; } = string.Empty; + + [JsonPropertyName("profile")] + public string Profile { get; init; } = string.Empty; + + [JsonPropertyName("succeeded")] + public bool Succeeded { get; init; } + + [JsonPropertyName("exitCode")] + public int ExitCode { get; init; } + + [JsonPropertyName("errorCount")] + public int ErrorCount { get; init; } + + [JsonPropertyName("warningCount")] + public int WarningCount { get; init; } + + [JsonPropertyName("results")] + public IReadOnlyList Results { get; init; } = []; + + [JsonPropertyName("errors")] + public IReadOnlyList Errors { get; init; } = []; + + [JsonPropertyName("warnings")] + public IReadOnlyList Warnings { get; init; } = []; + + /// + /// The report for a file that could not be read at all, reported as a single + /// finding so the shape never varies. + /// + public static PackageJsonValidationReport Unreadable(string file, string error) => + new() + { + File = file, + Profile = PackageJsonProfile.Auto.ToString(), + Succeeded = false, + ExitCode = 1, + ErrorCount = 1, + Results = + [ + new PackageJsonRuleReport + { + RuleId = PackageJsonRules.ReadFailureRuleId, + Outcome = PackageJsonRuleReport.Describe(PackageJsonRuleOutcome.Fail), + Message = error, + }, + ], + Errors = [$"{PackageJsonRules.ReadFailureRuleId}: {error}"], + }; + + internal static PackageJsonValidationReport From( + PackageJsonValidationContext context, + IReadOnlyList results, + bool treatWarningsAsErrors + ) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(results); + + var errors = results + .Where(result => result.Outcome == PackageJsonRuleOutcome.Fail) + .Select(Describe) + .ToArray(); + var warnings = results + .Where(result => result.Outcome == PackageJsonRuleOutcome.Warning) + .Select(Describe) + .ToArray(); + + var failed = errors.Length > 0 || (treatWarningsAsErrors && warnings.Length > 0); + + return new() + { + File = context.Document.Path, + Profile = context.Profile.ToString(), + Succeeded = !failed, + ExitCode = failed ? 1 : 0, + ErrorCount = errors.Length, + WarningCount = warnings.Length, + Results = + [ + .. results.Select(result => new PackageJsonRuleReport + { + RuleId = result.RuleId, + Outcome = PackageJsonRuleReport.Describe(result.Outcome), + Message = result.Message, + }), + ], + Errors = errors, + Warnings = warnings, + }; + } + + static string Describe(PackageJsonRuleResult result) => $"{result.RuleId}: {result.Message}"; + + static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + // Findings quote field names and versions with apostrophes. The default encoder escapes + // those to ', which is valid JSON but unreadable in a log. This output goes to a console and + // to jq, never into HTML. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + // Attaching the source-generated resolver keeps every option above while giving the + // serializer metadata it can see at compile time, which is the only form an AOT publish + // supports. + TypeInfoResolver = BuildJsonContext.Default, + }; + + /// + /// Renders the report as the JSON contract. + /// + /// + /// The type info comes from the source-generated resolver, so this works in a NativeAOT publish, + /// where reflection-based serialization throws. .NET 11 added the generic + /// GetTypeInfo<T>(); the net10.0 leg casts the non-generic result instead. + /// + public string ToJson() + { +#if NET11_0_OR_GREATER + var typeInfo = JsonOptions.GetTypeInfo(); +#else + var typeInfo = (System.Text.Json.Serialization.Metadata.JsonTypeInfo) + JsonOptions.GetTypeInfo(typeof(PackageJsonValidationReport)); +#endif + return JsonSerializer.Serialize(this, typeInfo); + } +} + +/// +/// One rule's outcome, as it appears in the JSON contract. +/// +public sealed record PackageJsonRuleReport +{ + [JsonPropertyName("ruleId")] + public string RuleId { get; init; } = string.Empty; + + [JsonPropertyName("outcome")] + public string Outcome { get; init; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; + + /// + /// The outcome strings of the JSON contract. Spelled out rather than lower-cased from the enum + /// name, so the contract cannot drift if the enum is renamed. + /// + public static string Describe(PackageJsonRuleOutcome outcome) => + outcome switch + { + PackageJsonRuleOutcome.Pass => "pass", + PackageJsonRuleOutcome.Warning => "warning", + PackageJsonRuleOutcome.Fail => "fail", + PackageJsonRuleOutcome.Skipped => "skipped", + _ => "unknown", + }; +} diff --git a/src/src/Build/PackageJson/PackageJsonValidationReportText.cs b/src/src/Build/PackageJson/PackageJsonValidationReportText.cs new file mode 100644 index 0000000..819fb5d --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonValidationReportText.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using System.Text; + +namespace Purview.Build.PackageJson; + +/// +/// Renders a for a human. +/// +static class PackageJsonValidationReportText +{ + public static string Render(PackageJsonValidationReport report) + { + ArgumentNullException.ThrowIfNull(report); + + StringBuilder builder = new(); + + builder.AppendLine(CultureInfo.InvariantCulture, $"package.json: {report.File}"); + builder.AppendLine(CultureInfo.InvariantCulture, $" profile: {report.Profile}"); + builder.AppendLine( + CultureInfo.InvariantCulture, + $" verdict: {(report.Succeeded ? "pass" : "FAIL")} (exit code {report.ExitCode})" + ); + builder.AppendLine(); + + builder.AppendLine("Rules, in evaluation order:"); + foreach (var result in report.Results) + { + builder.AppendLine( + CultureInfo.InvariantCulture, + $" {result.Outcome, -7} {result.RuleId} {result.Message}" + ); + } + + builder.AppendLine(); + + if (report.ErrorCount > 0) + { + builder.AppendLine( + CultureInfo.InvariantCulture, + $"{report.ErrorCount} error(s), {report.WarningCount} warning(s)." + ); + } + else if (report.WarningCount > 0) + { + builder.AppendLine( + CultureInfo.InvariantCulture, + $"{report.WarningCount} warning(s), no errors." + ); + } + + return builder.ToString().TrimEnd(); + } +} diff --git a/src/src/Build/PackageJson/PackageJsonValidationRunner.cs b/src/src/Build/PackageJson/PackageJsonValidationRunner.cs new file mode 100644 index 0000000..290fed2 --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonValidationRunner.cs @@ -0,0 +1,117 @@ +namespace Purview.Build.PackageJson; + +/// +/// Composes and runs a package.json validation from resolved configuration, so the pipeline +/// module and the standalone package-validate command cannot disagree about what is checked. +/// +static class PackageJsonValidationRunner +{ + /// + /// Runs the validation against the repository root package.json, reading the settings the + /// caller resolved. + /// + /// The package.json validation settings, including any rule opt-outs. + /// The build settings, which decide the project type and the scripts a Web run drives. + /// The version settings, whose strictness PKG002 reconciles against npm's. + /// The repository root, under which package.json is read. + /// + /// The version the pipeline resolved, or when the caller is the standalone + /// command and no pipeline has run. + /// + public static PackageJsonValidationReport Run( + PackageJsonValidationSettings settings, + BuildSettings build, + VersionSettings version, + string repositoryRoot, + string? resolvedVersion = null + ) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(build); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(repositoryRoot); + + var path = Path.Combine(repositoryRoot, "package.json"); + + if (!PackageJsonDocument.TryLoad(path, out var document, out var error)) + return PackageJsonValidationReport.Unreadable(path, error!); + + var profile = PackageJsonProfiles.Resolve( + settings.Profile, + build.ProjectType, + build.RunPack, + document!.IsPrivate + ); + + var (requiredScripts, recommendedScripts) = ScriptsFor(build); + + PackageJsonValidationContext context = new( + Document: document, + Profile: profile, + Strictness: version.Strictness, + ResolvedVersion: resolvedVersion, + RequiredScripts: requiredScripts, + RecommendedScripts: recommendedScripts, + AdditionalRequiredFields: settings.AdditionalRequiredFields + ); + + return PackageJsonValidator.Validate(settings, context); + } + + /// + /// Runs the validation from a resolved configuration root, for the standalone command. + /// + public static PackageJsonValidationReport Run( + IConfiguration configuration, + string repositoryRoot, + string? resolvedVersion = null + ) + { + ArgumentNullException.ThrowIfNull(configuration); + + return Run( + configuration + .GetSection(PackageJsonValidationSettings.SectionName) + .Get() ?? new(), + configuration.GetSection(BuildSettings.SectionName).Get() ?? new(), + configuration.GetSection(VersionSettings.SectionName).Get() ?? new(), + repositoryRoot, + resolvedVersion + ); + } + + /// + /// Splits the scripts a Web run drives into the ones it needs (the build command, whose absence + /// fails the build step anyway) and the ones best practice would add. + /// + static (IReadOnlyList Required, IReadOnlyList Recommended) ScriptsFor( + BuildSettings build + ) + { + if (build.ProjectType != ProjectType.Web) + return ([], []); + + List required = []; + List recommended = []; + + Add(required, build.WebBuildCommand); + + if (build.RunTests) + Add(recommended, build.WebTestCommand); + + if (build.RunLint) + { + Add(recommended, build.WebLintCommand); + Add(recommended, build.WebFormatCheckCommand); + } + + return (required, recommended); + + static void Add(List target, string command) + { + var name = WebScripts.GetScriptName(command); + if (name is not null && !target.Contains(name, StringComparer.OrdinalIgnoreCase)) + target.Add(name); + } + } +} diff --git a/src/src/Build/PackageJson/PackageJsonValidator.cs b/src/src/Build/PackageJson/PackageJsonValidator.cs new file mode 100644 index 0000000..4f5fec8 --- /dev/null +++ b/src/src/Build/PackageJson/PackageJsonValidator.cs @@ -0,0 +1,36 @@ +namespace Purview.Build.PackageJson; + +/// +/// Evaluates every applicable rule against one package.json and composes the report. +/// +/// +/// Unlike the release-eligibility evaluator this is a gate: an error-level finding fails whatever +/// asked for the check, because "is this file good enough to publish" is the question being +/// answered. Warning-level findings are reported and do not fail unless +/// says otherwise. +/// +static class PackageJsonValidator +{ + public static PackageJsonValidationReport Validate( + PackageJsonValidationSettings settings, + PackageJsonValidationContext context + ) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(context); + + HashSet disabled = [with(StringComparer.OrdinalIgnoreCase), .. settings.DisabledRules]; + + List results = [with(PackageJsonRules.All.Count)]; + foreach (var rule in PackageJsonRules.All) + { + results.Add( + disabled.Contains(rule.Id) + ? PackageJsonRuleResult.Skipped(rule.Id, "Disabled by PackageJson:DisabledRules.") + : rule.Evaluate(context) + ); + } + + return PackageJsonValidationReport.From(context, results, settings.TreatWarningsAsErrors); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg001NameRule.cs b/src/src/Build/PackageJson/Rules/Pkg001NameRule.cs new file mode 100644 index 0000000..3ba660f --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg001NameRule.cs @@ -0,0 +1,39 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG001 — the package has a name, and npm will accept it when the file is published there. +/// +sealed class Pkg001NameRule : IPackageJsonRule +{ + public string Id => "PKG001"; + + public string Description => "Package name is present and valid for the target registry"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var name = context.Document.GetString("name"); + + if (string.IsNullOrWhiteSpace(name)) + return PackageJsonRuleResult.Fail(Id, "The 'name' field is missing or empty."); + + if (!PackageJsonProfiles.IsWeb(context.Profile)) + return PackageJsonRuleResult.Pass(Id, $"Name '{name}' is present."); + + if (NpmNames.DescribeProblem(name) is not { } problem) + return PackageJsonRuleResult.Pass(Id, $"Name '{name}' is a valid npm package name."); + + // A published package is rejected outright; an application's name is never sent to npm, so + // the same defect is only advisory there. + return context.Profile == PackageJsonProfile.WebPackage + ? PackageJsonRuleResult.Fail( + Id, + $"Name '{name}' is not a valid npm package name: {problem}." + ) + : PackageJsonRuleResult.Warning( + Id, + $"Name '{name}' would be rejected by npm: {problem}." + ); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg002VersionRule.cs b/src/src/Build/PackageJson/Rules/Pkg002VersionRule.cs new file mode 100644 index 0000000..362aede --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg002VersionRule.cs @@ -0,0 +1,66 @@ +using NuGet.Versioning; + +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG002 — the version is present, and acceptable to every registry the file serves. +/// +/// +/// The pipeline's own gate is , which defaults to NuGet and so +/// accepts four-part versions such as 13.5.3.10. npm accepts only SemVer 2.0, which is why +/// the same value is fatal for a published npm package and merely advisory for anything else. +/// +sealed class Pkg002VersionRule : IPackageJsonRule +{ + public string Id => "PKG002"; + + public string Description => "Version is present and valid for every registry the file serves"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var raw = context.Document.GetString("version"); + + if (string.IsNullOrWhiteSpace(raw)) + return PackageJsonRuleResult.Fail( + Id, + "The 'version' field is missing or empty. It is the pipeline's release version." + ); + + if (SemanticVersion.TryParse(raw, out _)) + { + if (raw is "0.0.0") + { + return PackageJsonRuleResult.Warning( + Id, + "The version is the '0.0.0' placeholder. Set a real version before releasing." + ); + } + + return PackageJsonRuleResult.Pass( + Id, + context.ResolvedVersion is null + ? $"Version '{raw}' is valid SemVer 2.0." + : $"Version '{raw}' is valid SemVer 2.0 and is the version the pipeline resolved." + ); + } + + if (NuGetVersion.TryParse(raw, out _)) + { + return context.Profile == PackageJsonProfile.WebPackage + ? PackageJsonRuleResult.Fail( + Id, + $"Version '{raw}' is not valid SemVer 2.0, and npm rejects anything else. " + + "Use a three-part version." + ) + : PackageJsonRuleResult.Warning( + Id, + $"Version '{raw}' is accepted by NuGet (Version:Strictness={context.Strictness}) but " + + "is not SemVer 2.0, so npm and other strict consumers reject it." + ); + } + + return PackageJsonRuleResult.Fail(Id, $"Version '{raw}' is not a valid version."); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg003DescriptionRule.cs b/src/src/Build/PackageJson/Rules/Pkg003DescriptionRule.cs new file mode 100644 index 0000000..b5e2b09 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg003DescriptionRule.cs @@ -0,0 +1,47 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG003 — the package says what it is. +/// +/// +/// The description is what a registry search result, a directory listing and a feed entry show, so an +/// absent one is an error for every profile; a token one is advisory. +/// +sealed class Pkg003DescriptionRule : IPackageJsonRule +{ + const int MinimumSubstantiveLength = 10; + + public string Id => "PKG003"; + + public string Description => "Description is present and says what the package does"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var description = context.Document.GetString("description"); + + if (string.IsNullOrWhiteSpace(description)) + return PackageJsonRuleResult.Fail( + Id, + "The 'description' field is missing or empty. It is what a registry search result and a " + + "directory listing show." + ); + + var trimmed = description.Trim(); + + if ( + trimmed.Length < MinimumSubstantiveLength + || string.Equals(trimmed, context.Document.GetString("name"), StringComparison.Ordinal) + ) + { + return PackageJsonRuleResult.Warning( + Id, + $"The description '{trimmed}' says too little. Describe what the package does and who " + + "it is for." + ); + } + + return PackageJsonRuleResult.Pass(Id, "A description is present."); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg004LicenseRule.cs b/src/src/Build/PackageJson/Rules/Pkg004LicenseRule.cs new file mode 100644 index 0000000..3b66a29 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg004LicenseRule.cs @@ -0,0 +1,90 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG004 — the package declares a licence a consumer can act on. +/// +/// +/// The identifier is checked against the common SPDX short forms rather than the full licence list: +/// an unrecognised value is advisory, so a correct but unlisted identifier never fails a build. +/// +sealed class Pkg004LicenseRule : IPackageJsonRule +{ + static readonly HashSet KnownSpdxIdentifiers = new(StringComparer.OrdinalIgnoreCase) + { + "0BSD", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "Apache-2.0", + "Artistic-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDDL-1.0", + "Elastic-2.0", + "EPL-2.0", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-3.0-only", + "GPL-3.0-or-later", + "ISC", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "MIT", + "MIT-0", + "MPL-2.0", + "MS-PL", + "MS-RL", + "NCSA", + "PostgreSQL", + "Python-2.0", + "Unlicense", + "WTFPL", + "Zlib", + }; + + public string Id => "PKG004"; + + public string Description => "License is present and is an SPDX identifier"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var license = context.Document.GetString("license"); + + if (string.IsNullOrWhiteSpace(license)) + return PackageJsonRuleResult.Fail( + Id, + "The 'license' field is missing. Declare an SPDX identifier such as 'MIT', or " + + "'SEE LICENSE IN LICENSE.md'." + ); + + if (license.StartsWith("SEE LICENSE IN ", StringComparison.OrdinalIgnoreCase)) + return PackageJsonRuleResult.Pass(Id, $"License is declared as '{license}'."); + + if (string.Equals(license, "UNLICENSED", StringComparison.OrdinalIgnoreCase)) + { + return PackageJsonProfiles.Publishes(context.Profile) + ? PackageJsonRuleResult.Warning( + Id, + "The package is published but licensed 'UNLICENSED'. Declare a real licence, or stop " + + "publishing it." + ) + : PackageJsonRuleResult.Pass( + Id, + "The package is not published, so 'UNLICENSED' is appropriate." + ); + } + + return KnownSpdxIdentifiers.Contains(license) + ? PackageJsonRuleResult.Pass(Id, $"License '{license}' is a recognised SPDX identifier.") + : PackageJsonRuleResult.Warning( + Id, + $"License '{license}' is not a recognised SPDX identifier. Use an SPDX expression such " + + "as 'MIT' or 'Apache-2.0', or 'SEE LICENSE IN '." + ); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg005RepositoryRule.cs b/src/src/Build/PackageJson/Rules/Pkg005RepositoryRule.cs new file mode 100644 index 0000000..251d4f1 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg005RepositoryRule.cs @@ -0,0 +1,46 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG005 — the source is discoverable, in the form registries understand. +/// +sealed class Pkg005RepositoryRule : IPackageJsonRule +{ + public string Id => "PKG005"; + + public string Description => "Repository is declared as an object with a type and a url"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!context.Document.Has("repository")) + { + var advice = + "Add a 'repository' object so consumers can find, inspect and contribute to the source."; + + return PackageJsonProfiles.Publishes(context.Profile) + ? PackageJsonRuleResult.Fail(Id, $"The 'repository' field is missing. {advice}") + : PackageJsonRuleResult.Warning(Id, $"The 'repository' field is missing. {advice}"); + } + + if (context.Document.GetObject("repository") is null) + { + return PackageJsonRuleResult.Warning( + Id, + "Write 'repository' as an object with 'type' and 'url'; the shorthand string form omits " + + "the type registries link against." + ); + } + + if (string.IsNullOrWhiteSpace(context.Document.GetNestedString("repository", "url"))) + return PackageJsonRuleResult.Warning(Id, "'repository.url' is missing or empty."); + + if (string.IsNullOrWhiteSpace(context.Document.GetNestedString("repository", "type"))) + return PackageJsonRuleResult.Warning( + Id, + "'repository.type' is missing. Declare it, e.g. \"type\": \"git\"." + ); + + return PackageJsonRuleResult.Pass(Id, "A repository type and url are declared."); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg006AuthorRule.cs b/src/src/Build/PackageJson/Rules/Pkg006AuthorRule.cs new file mode 100644 index 0000000..95485a3 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg006AuthorRule.cs @@ -0,0 +1,53 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG006 — the maintainer is named. +/// +sealed class Pkg006AuthorRule : IPackageJsonRule +{ + public string Id => "PKG006"; + + public string Description => "Author is declared, as a string or an object with a name"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!context.Document.Has("author")) + { + var advice = "Add 'author' so the package has a named maintainer."; + + return PackageJsonProfiles.Publishes(context.Profile) + ? PackageJsonRuleResult.Fail(Id, $"The 'author' field is missing. {advice}") + : PackageJsonRuleResult.Warning(Id, $"The 'author' field is missing. {advice}"); + } + + // npm accepts the "Name (url)" string form as well as the object form. + if (context.Document.GetString("author") is { } text) + { + return string.IsNullOrWhiteSpace(text) + ? PackageJsonRuleResult.Fail(Id, "The 'author' field is empty.") + : PackageJsonRuleResult.Pass(Id, $"Author '{text}' is declared."); + } + + if (context.Document.GetObject("author") is not { } author) + { + return PackageJsonRuleResult.Warning( + Id, + "Write 'author' as a string or an object; other shapes are not read by registries." + ); + } + + var name = context.Document.GetNestedString("author", "name"); + + if (string.IsNullOrWhiteSpace(name)) + return PackageJsonRuleResult.Fail(Id, "'author.name' is missing or empty."); + + return PackageJsonRuleResult.Pass( + Id, + author.ContainsKey("email") || author.ContainsKey("url") + ? $"Author '{name}' is declared with contact details." + : $"Author '{name}' is declared." + ); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg007LinksRule.cs b/src/src/Build/PackageJson/Rules/Pkg007LinksRule.cs new file mode 100644 index 0000000..3b85f5d --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg007LinksRule.cs @@ -0,0 +1,28 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG007 — users have somewhere to read the docs and report a problem. +/// +sealed class Pkg007LinksRule : IPackageJsonRule +{ + public string Id => "PKG007"; + + public string Description => "Homepage or a bug tracker is declared"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var homepage = context.Document.GetString("homepage"); + var bugs = context.Document.GetNestedString("bugs", "url"); + + if (!string.IsNullOrWhiteSpace(homepage) || !string.IsNullOrWhiteSpace(bugs)) + return PackageJsonRuleResult.Pass(Id, "A homepage or bug tracker is declared."); + + return PackageJsonRuleResult.Warning( + Id, + "Neither 'homepage' nor 'bugs.url' is set. Users need somewhere to read the docs and " + + "report a problem." + ); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg008KeywordsRule.cs b/src/src/Build/PackageJson/Rules/Pkg008KeywordsRule.cs new file mode 100644 index 0000000..7d4c8d4 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg008KeywordsRule.cs @@ -0,0 +1,36 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG008 — the package is discoverable in a registry search. +/// +/// +/// Only meaningful where the file is published: a private application's keywords are never indexed. +/// +sealed class Pkg008KeywordsRule : IPackageJsonRule +{ + public string Id => "PKG008"; + + public string Description => "Keywords are declared for a published package"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!PackageJsonProfiles.Publishes(context.Profile)) + { + return PackageJsonRuleResult.Skipped( + Id, + "Keywords only affect registry discovery, and this file is not published to a registry." + ); + } + + var keywords = context.Document.GetStrings("keywords"); + + return keywords.Count > 0 + ? PackageJsonRuleResult.Pass(Id, $"{keywords.Count} keyword(s) are declared.") + : PackageJsonRuleResult.Warning( + Id, + "No 'keywords' are declared, so the package is hard to find in a registry search." + ); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg009PrivateIntentRule.cs b/src/src/Build/PackageJson/Rules/Pkg009PrivateIntentRule.cs new file mode 100644 index 0000000..eaba5f9 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg009PrivateIntentRule.cs @@ -0,0 +1,44 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG009 — private agrees with what the repository does with the file. +/// +/// +/// In npm terms the absence of private means "publishable". For a .NET repository the file +/// only carries the release version, so a missing flag is a warning; for a web application it is the +/// difference between a private app and an accidental publish, which is an error. +/// +sealed class Pkg009PrivateIntentRule : IPackageJsonRule +{ + public string Id => "PKG009"; + + public string Description => "The private flag matches whether the file is published"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var isPrivate = context.Document.IsPrivate; + + return context.Profile switch + { + PackageJsonProfile.WebPackage when isPrivate => PackageJsonRuleResult.Fail( + Id, + "'private' is true, so npm refuses to publish this package. Remove 'private', or set " + + "PackageJson:Profile=WebApp if this is an application." + ), + PackageJsonProfile.WebApp when !isPrivate => PackageJsonRuleResult.Fail( + Id, + "This file describes an application, so it must not be publishable. Set 'private' to " + + "true, or set PackageJson:Profile=WebPackage if it really is published." + ), + PackageJsonProfile.DotNetNuGet or PackageJsonProfile.DotNetSolution when !isPrivate => + PackageJsonRuleResult.Warning( + Id, + "The file is not 'private', which in npm terms means publishable. A .NET repository's " + + "package.json only carries the release version, so set 'private' to true." + ), + _ => PackageJsonRuleResult.Pass(Id, "The 'private' flag matches how this file is used."), + }; + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg010EnginesRule.cs b/src/src/Build/PackageJson/Rules/Pkg010EnginesRule.cs new file mode 100644 index 0000000..b496229 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg010EnginesRule.cs @@ -0,0 +1,41 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG010 — the supported runtime is stated where a runtime is the thing that runs it. +/// +sealed class Pkg010EnginesRule : IPackageJsonRule +{ + public string Id => "PKG010"; + + public string Description => "engines.node declares the supported Node version range"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!PackageJsonProfiles.IsWeb(context.Profile)) + { + return PackageJsonRuleResult.Skipped( + Id, + "Runtime engines only apply where npm is the consumer, so a .NET repository's file " + + "does not need them." + ); + } + + var node = context.Document.GetNestedString("engines", "node"); + + if (!string.IsNullOrWhiteSpace(node)) + return PackageJsonRuleResult.Pass(Id, $"'engines.node' declares '{node}'."); + + return context.Profile == PackageJsonProfile.WebPackage + ? PackageJsonRuleResult.Fail( + Id, + "No 'engines.node' range is declared. A consumer installing the package needs to know " + + "which Node versions are supported." + ) + : PackageJsonRuleResult.Warning( + Id, + "No 'engines.node' range is declared. State the Node version the project supports." + ); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg011EntryPointsRule.cs b/src/src/Build/PackageJson/Rules/Pkg011EntryPointsRule.cs new file mode 100644 index 0000000..ae779ab --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg011EntryPointsRule.cs @@ -0,0 +1,47 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG011 — a published package declares how it is imported. +/// +sealed class Pkg011EntryPointsRule : IPackageJsonRule +{ + static readonly string[] EntryPointFields = + [ + "main", + "module", + "exports", + "types", + "typings", + "bin", + ]; + + public string Id => "PKG011"; + + public string Description => "A published package declares at least one entry point"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Profile != PackageJsonProfile.WebPackage) + { + return PackageJsonRuleResult.Skipped( + Id, + "Entry points only matter for a package that is published and then imported." + ); + } + + var declared = EntryPointFields.Where(context.Document.HasNonEmpty).ToArray(); + + return declared.Length > 0 + ? PackageJsonRuleResult.Pass( + Id, + $"Entry point(s) declared: {string.Join(", ", declared)}." + ) + : PackageJsonRuleResult.Warning( + Id, + "None of 'main', 'module', 'exports', 'types' or 'bin' is declared, so a consumer cannot " + + "import the package." + ); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg012ScriptsRule.cs b/src/src/Build/PackageJson/Rules/Pkg012ScriptsRule.cs new file mode 100644 index 0000000..a875918 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg012ScriptsRule.cs @@ -0,0 +1,66 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG012 — the scripts the pipeline drives are declared in the file that defines them. +/// +/// +/// A Web run executes Build:Web*Command through package.json scripts. A script the +/// build step needs and does not find fails the run later anyway; naming it here says why, before +/// anything is built. +/// +sealed class Pkg012ScriptsRule : IPackageJsonRule +{ + public string Id => "PKG012"; + + public string Description => "The package.json scripts this run drives are declared"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.RequiredScripts.Count == 0 && context.RecommendedScripts.Count == 0) + { + return PackageJsonRuleResult.Skipped( + Id, + "This run drives no package.json scripts." + ); + } + + var scripts = context.Document.GetObject("scripts"); + + var required = Missing(scripts, context.RequiredScripts); + if (required.Count > 0) + { + return PackageJsonRuleResult.Fail( + Id, + $"The scripts this run executes are not declared: {string.Join(", ", required)}. Add them " + + "to 'scripts', or point Build:Web*Command at scripts that exist." + ); + } + + var recommended = Missing(scripts, context.RecommendedScripts); + if (recommended.Count > 0) + { + return PackageJsonRuleResult.Warning( + Id, + $"Consider declaring {string.Join(", ", recommended)} in 'scripts', so the pipeline and " + + "contributors run the same commands." + ); + } + + return PackageJsonRuleResult.Pass(Id, "Every script this run drives is declared."); + } + + static List Missing(System.Text.Json.Nodes.JsonObject? scripts, IReadOnlyList names) + { + List missing = []; + + foreach (var name in names) + { + if (scripts is null || !scripts.ContainsKey(name)) + missing.Add(name); + } + + return missing; + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg013FilesRule.cs b/src/src/Build/PackageJson/Rules/Pkg013FilesRule.cs new file mode 100644 index 0000000..b36e166 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg013FilesRule.cs @@ -0,0 +1,34 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG013 — the published tarball is deliberate rather than "everything that is not ignored". +/// +sealed class Pkg013FilesRule : IPackageJsonRule +{ + public string Id => "PKG013"; + + public string Description => "A published package declares a files allow-list"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Profile != PackageJsonProfile.WebPackage) + { + return PackageJsonRuleResult.Skipped( + Id, + "A publish allow-list only matters for a package published to npm." + ); + } + + var files = context.Document.GetStrings("files"); + + return files.Count > 0 + ? PackageJsonRuleResult.Pass(Id, $"'files' declares {files.Count} path(s).") + : PackageJsonRuleResult.Warning( + Id, + "No 'files' allow-list is declared, so npm publishes everything that is not ignored. " + + "Declare 'files' so the tarball is deliberate." + ); + } +} diff --git a/src/src/Build/PackageJson/Rules/Pkg014AdditionalRequiredFieldsRule.cs b/src/src/Build/PackageJson/Rules/Pkg014AdditionalRequiredFieldsRule.cs new file mode 100644 index 0000000..4384e03 --- /dev/null +++ b/src/src/Build/PackageJson/Rules/Pkg014AdditionalRequiredFieldsRule.cs @@ -0,0 +1,43 @@ +namespace Purview.Build.PackageJson.Rules; + +/// +/// PKG014 — the repository's own extra requirements are met. +/// +/// +/// The escape hatch for a field that matters to one organisation and not to the ecosystem: +/// PackageJson:AdditionalRequiredFields names top-level fields that must be present and +/// non-empty. +/// +sealed class Pkg014AdditionalRequiredFieldsRule : IPackageJsonRule +{ + public string Id => "PKG014"; + + public string Description => "Every field named by PackageJson:AdditionalRequiredFields is present"; + + public PackageJsonRuleResult Evaluate(PackageJsonValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.AdditionalRequiredFields.Count == 0) + { + return PackageJsonRuleResult.Skipped( + Id, + "PackageJson:AdditionalRequiredFields is empty." + ); + } + + var missing = context.AdditionalRequiredFields.Where(field => !context.Document.HasNonEmpty(field)); + + return missing.Any() + ? PackageJsonRuleResult.Fail( + Id, + $"PackageJson:AdditionalRequiredFields requires {string.Join(", ", missing)}, which " + + "is missing or empty." + ) + : PackageJsonRuleResult.Pass( + Id, + $"Every configured additional field is present " + + $"({string.Join(", ", context.AdditionalRequiredFields)})." + ); + } +} diff --git a/src/src/Build/Program.cs b/src/src/Build/Program.cs index 01a25f8..f6e1aa2 100644 --- a/src/src/Build/Program.cs +++ b/src/src/Build/Program.cs @@ -1,4 +1,5 @@ using Purview.Build.Configuration; +using Purview.Build.PackageJson; using Purview.Build.Release; var commandLine = ToolCommandLine.Parse(args); @@ -38,6 +39,20 @@ return 0; } + if (commandLine.Command == ToolCommand.PackageValidate) + { + var configuration = ConfigurationChain.Build(startup, commandLine.PipelineArguments); + var report = PackageJsonValidationRunner.Run(configuration, startup.RepositoryRoot); + + // Unlike release-explain this is a gate, so it exits non-zero when it fails: that is the + // whole point of calling it. The report carries the same exit code for a caller that reads it. + CLIConsole.WriteRaw( + commandLine.Format == OutputFormat.Json ? report.ToJson() : PackageJsonValidationReportText.Render(report) + ); + + return report.ExitCode; + } + // Identify the tool up front, so CI job logs and local runs show which build tool executed the // pipeline, and which configuration it read. CLIConsole.WriteLine(ToolInfo.VersionLine); diff --git a/src/src/Build/Release/BuildJsonContext.cs b/src/src/Build/Release/BuildJsonContext.cs index 6c18059..4b2a395 100644 --- a/src/src/Build/Release/BuildJsonContext.cs +++ b/src/src/Build/Release/BuildJsonContext.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Purview.Build.PackageJson; namespace Purview.Build.Release; @@ -13,5 +14,6 @@ namespace Purview.Build.Release; /// cost from the ordinary framework-dependent tool, so the metadata is worth having either way. /// [JsonSerializable(typeof(ReleaseExplanation))] +[JsonSerializable(typeof(PackageJsonValidationReport))] [JsonSerializable(typeof(string[]))] partial class BuildJsonContext : JsonSerializerContext; diff --git a/src/src/Build/Schema/ConfigSchemaGenerator.cs b/src/src/Build/Schema/ConfigSchemaGenerator.cs index 81d55ac..21e2ac3 100644 --- a/src/src/Build/Schema/ConfigSchemaGenerator.cs +++ b/src/src/Build/Schema/ConfigSchemaGenerator.cs @@ -37,6 +37,7 @@ static readonly (string Section, Type Type)[] Sections = [ (BuildSettings.SectionName, typeof(BuildSettings)), (PackValidationSettings.SectionName, typeof(PackValidationSettings)), + (PackageJsonValidationSettings.SectionName, typeof(PackageJsonValidationSettings)), (NuGetSettings.SectionName, typeof(NuGetSettings)), (PublishLocalNuGetSettings.SectionName, typeof(PublishLocalNuGetSettings)), (GitHubSettings.SectionName, typeof(GitHubSettings)), diff --git a/src/src/Build/Settings/PackageJsonValidationSettings.cs b/src/src/Build/Settings/PackageJsonValidationSettings.cs new file mode 100644 index 0000000..4967c90 --- /dev/null +++ b/src/src/Build/Settings/PackageJsonValidationSettings.cs @@ -0,0 +1,73 @@ +namespace Purview.Build.Settings; + +/// +/// Which registry the repository root package.json serves, and therefore which set of content +/// rules applies to it. +/// +public enum PackageJsonProfile +{ + /// + /// Derived from , whether the repository packs, and the + /// file's own private field. + /// + Auto, + + /// + /// A .NET repository that packs NuGet package(s). The file carries the release version and + /// metadata; it is never published to npm. + /// + DotNetNuGet, + + /// A .NET repository that packs nothing. The file carries the release version only. + DotNetSolution, + + /// A Web repository whose file is published to npm as a package. + WebPackage, + + /// A Web application. The file is private and is never published. + WebApp, +} + +/// +/// Opt-in validation of the repository root package.json against the content a +/// best-in-class package of the repository's kind is expected to declare. +/// +/// +/// Off by default: the file already exists in every consuming repository, so enabling this is a +/// deliberate decision to hold it to a standard. The package-validate command runs the same +/// checks on their own, whether or not this section enables the pipeline step. +/// +public sealed record PackageJsonValidationSettings +{ + public const string SectionName = "PackageJson"; + + /// + /// When true, runs as part of the pipeline. + /// + /// Defaults to : the check is opt-in. + public bool Enabled { get; init; } + + /// + /// The registry profile whose rules apply, or to derive it + /// from the project type and the file itself. + /// + public PackageJsonProfile Profile { get; init; } = PackageJsonProfile.Auto; + + /// + /// Rule IDs to skip, e.g. ["PKG008"] for a repository that deliberately declares no + /// keywords. Unknown IDs are ignored. + /// + public string[] DisabledRules { get; init; } = []; + + /// + /// Extra top-level fields the repository requires on top of the built-in rules, e.g. + /// ["funding"]. Any that is missing or empty is reported by PKG014. + /// + public string[] AdditionalRequiredFields { get; init; } = []; + + /// + /// When true, a warning-level finding fails the run exactly as an error-level one does. Useful + /// once the file is clean, to stop it drifting back. + /// + public bool TreatWarningsAsErrors { get; init; } +} diff --git a/src/src/Build/appsettings.json b/src/src/Build/appsettings.json index 0d2957a..4b40226 100644 --- a/src/src/Build/appsettings.json +++ b/src/src/Build/appsettings.json @@ -35,6 +35,13 @@ "RequiredContent": {}, "ForbiddenContent": {} }, + "PackageJson": { + "Enabled": false, + "Profile": "Auto", + "DisabledRules": [], + "AdditionalRequiredFields": [], + "TreatWarningsAsErrors": false + }, "NuGet": { "FeedUrl": "https://api.nuget.org/v3/index.json", "TrustedPublishing": false, diff --git a/src/tests/Build.UnitTests/Infra/Scenarios.cs b/src/tests/Build.UnitTests/Infra/Scenarios.cs index 21f30dd..dec7c9b 100644 --- a/src/tests/Build.UnitTests/Infra/Scenarios.cs +++ b/src/tests/Build.UnitTests/Infra/Scenarios.cs @@ -29,6 +29,9 @@ public static class Scenarios public static ConfigScenarioFile ConfigResolution { get; } = Load("config-resolution-scenarios.json"); + public static PackageJsonScenarioFile PackageJson { get; } = + Load("package-json-scenarios.json"); + static T Load(string fileName) { var path = Path.Combine(FixturesDirectory, fileName); @@ -148,3 +151,63 @@ public sealed record ExpectedConfig public bool? UserConfigActive { get; init; } } + +public sealed record PackageJsonScenarioFile +{ + public IReadOnlyList Scenarios { get; init; } = []; +} + +/// +/// One package.json validation case: the file's contents, the configuration the run resolves, +/// and the findings the run must produce. +/// +/// +/// Both the TUnit suite and the just package-matrix recipe build the same settings and write +/// the same file, so a mismatch means the shipped CLI and the in-process runner disagree. +/// +public sealed record PackageJsonScenario +{ + public string Name { get; init; } = string.Empty; + + public string Why { get; init; } = string.Empty; + + public string Profile { get; init; } = "Auto"; + + public string ProjectType { get; init; } = "DotNet"; + + public bool PacksNuGetPackages { get; init; } + + public bool RunTests { get; init; } = true; + + public bool RunLint { get; init; } = true; + + public IReadOnlyList AdditionalRequiredFields { get; init; } = []; + + public IReadOnlyList DisabledRules { get; init; } = []; + + public bool TreatWarningsAsErrors { get; init; } + + /// The file's contents, used verbatim (an array is deliberately unreadable). + public JsonElement PackageJson { get; init; } + + public ExpectedPackageJson Expected { get; init; } = new(); + + public override string ToString() => Name; +} + +/// +/// What a scenario must produce. The rule lists are rule IDs, not messages, so rewording a finding +/// is not a fixture change. +/// +public sealed record ExpectedPackageJson +{ + public bool Succeeded { get; init; } + + public int ExitCode { get; init; } + + public string Profile { get; init; } = string.Empty; + + public IReadOnlyList Errors { get; init; } = []; + + public IReadOnlyList Warnings { get; init; } = []; +} diff --git a/src/tests/Build.UnitTests/PackageJsonScenarioTests.cs b/src/tests/Build.UnitTests/PackageJsonScenarioTests.cs new file mode 100644 index 0000000..e08a585 --- /dev/null +++ b/src/tests/Build.UnitTests/PackageJsonScenarioTests.cs @@ -0,0 +1,140 @@ +using Purview.Build.Infra; +using Purview.Build.PackageJson; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// Every case in src/tests/fixtures/package-json-scenarios.json, driven from the file. +/// +/// +/// Adding a rule means appending cases to that file; no test code changes. The same file drives +/// just package-matrix, which runs each case through the real CLI, so the in-process runner +/// and the shipped binary are held to one specification. +/// +public class PackageJsonScenarioTests +{ + public static IEnumerable> AllScenarios() => + Scenarios.PackageJson.Scenarios.Select>(scenario => + () => scenario + ); + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Validate_GivenScenario_MatchesTheExpectedProfileAndVerdict(PackageJsonScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + var report = await RunAsync(scenario); + + await Assert.That(report.Profile).IsEqualTo(scenario.Expected.Profile).Because(Describe(scenario, report)); + + await Assert.That(report.Succeeded).IsEqualTo(scenario.Expected.Succeeded).Because(Describe(scenario, report)); + + await Assert.That(report.ExitCode).IsEqualTo(scenario.Expected.ExitCode).Because(Describe(scenario, report)); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Validate_GivenScenario_FailsTheExpectedRules(PackageJsonScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + var report = await RunAsync(scenario); + + await Assert + .That(RuleIds(report, "fail")) + .IsEquivalentTo(scenario.Expected.Errors) + .Because(Describe(scenario, report)); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Validate_GivenScenario_WarnsOnTheExpectedRules(PackageJsonScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + var report = await RunAsync(scenario); + + await Assert + .That(RuleIds(report, "warning")) + .IsEquivalentTo(scenario.Expected.Warnings) + .Because(Describe(scenario, report)); + } + + [Test] + public async Task AllScenarios_NameOnlyKnownRuleIds() + { + // Guards the fixture itself: a typo in an expected rule ID would otherwise silently assert + // against a rule that does not exist. + var referenced = Scenarios + .PackageJson.Scenarios.SelectMany(scenario => scenario.Expected.Errors.Concat(scenario.Expected.Warnings)) + .Distinct(); + + foreach (var ruleId in referenced) + { + await Assert + .That(PackageJsonRules.IsKnown(ruleId)) + .IsTrue() + .Because($"the fixture references '{ruleId}', which is not a known rule."); + } + } + + [Test] + public async Task AllScenarios_ExerciseEveryRule() + { + var referenced = Scenarios + .PackageJson.Scenarios.SelectMany(scenario => scenario.Expected.Errors.Concat(scenario.Expected.Warnings)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var unexercised = PackageJsonRules + .All.Select(rule => rule.Id) + .Where(ruleId => !referenced.Contains(ruleId)) + .ToArray(); + + await Assert + .That(unexercised) + .IsEmpty() + .Because("a rule with no scenario is a rule nobody has specified: " + string.Join(", ", unexercised)); + } + + static async Task RunAsync(PackageJsonScenario scenario) + { + var directory = Path.Combine(Path.GetTempPath(), "purview-build-packagejson-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + try + { + await File.WriteAllTextAsync(Path.Combine(directory, "package.json"), scenario.PackageJson.GetRawText()); + + BuildSettings build = new() + { + ProjectType = Enum.Parse(scenario.ProjectType, ignoreCase: true), + RunPack = scenario.PacksNuGetPackages, + RunTests = scenario.RunTests, + RunLint = scenario.RunLint, + }; + + PackageJsonValidationSettings settings = new() + { + Enabled = true, + Profile = Enum.Parse(scenario.Profile, ignoreCase: true), + DisabledRules = [.. scenario.DisabledRules], + AdditionalRequiredFields = [.. scenario.AdditionalRequiredFields], + TreatWarningsAsErrors = scenario.TreatWarningsAsErrors, + }; + + return PackageJsonValidationRunner.Run(settings, build, new VersionSettings(), directory); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + static IReadOnlyList RuleIds(PackageJsonValidationReport report, string outcome) => + [.. report.Results.Where(result => result.Outcome == outcome).Select(result => result.RuleId)]; + + static string Describe(PackageJsonScenario scenario, PackageJsonValidationReport report) => + $"{scenario.Name}: {scenario.Why}{Environment.NewLine}{PackageJsonValidationReportText.Render(report)}"; +} diff --git a/src/tests/Build.UnitTests/PackageJsonValidationTests.cs b/src/tests/Build.UnitTests/PackageJsonValidationTests.cs new file mode 100644 index 0000000..9af9163 --- /dev/null +++ b/src/tests/Build.UnitTests/PackageJsonValidationTests.cs @@ -0,0 +1,282 @@ +using System.Text.Json; +using Purview.Build.PackageJson; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// The parts of package.json validation that the scenario fixture does not drive: the npm +/// name rules, profile resolution, the unreadable-file path, and the JSON contract. +/// +public class PackageJsonValidationTests +{ + // A file that satisfies every rule for the profile it is read as. + const string CompleteDotNetDocument = /*lang=json,strict*/ + """ + { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + } + """; + + [Test] + public async Task Validate_GivenACompleteDocument_PassesEveryRule() + { + var report = Validate(CompleteDotNetDocument, PackageJsonProfile.DotNetNuGet); + + await Assert.That(report.Succeeded).IsTrue().Because(PackageJsonValidationReportText.Render(report)); + await Assert.That(report.ErrorCount).IsEqualTo(0); + await Assert.That(report.WarningCount).IsEqualTo(0); + await Assert.That(report.ExitCode).IsEqualTo(0); + } + + [Test] + public async Task Validate_GivenAnEmptyDocument_StillReportsEveryRule() + { + // One missing field must not hide the rest of the findings; the report is the whole picture. + var report = Validate("{}", PackageJsonProfile.DotNetNuGet); + + await Assert + .That(report.Results.Select(result => result.RuleId)) + .IsEquivalentTo(PackageJsonRules.All.Select(rule => rule.Id)); + } + + [Test] + public async Task Validate_GivenARuleDisabledInConfiguration_SkipsItRatherThanFailingIt() + { + const string document = /*lang=json,strict*/ + """ + { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "private": true + } + """; + + var report = Validate(document, PackageJsonProfile.DotNetNuGet, disabledRules: ["PKG008"]); + + await Assert.That(report.Succeeded).IsTrue().Because(PackageJsonValidationReportText.Render(report)); + await Assert.That(report.Results.Single(result => result.RuleId == "PKG008").Outcome).IsEqualTo("skipped"); + } + + [Test] + public async Task Validate_GivenAnUnparseableFile_ReportsPkg000WithoutThrowing() + { + var report = Validate("this is not JSON", PackageJsonProfile.DotNetNuGet); + + await Assert.That(report.Succeeded).IsFalse(); + await Assert.That(report.ExitCode).IsEqualTo(1); + await Assert.That(report.Results).Count().IsEqualTo(1); + await Assert.That(report.Results[0].RuleId).IsEqualTo(PackageJsonRules.ReadFailureRuleId); + } + + [Test] + public async Task Validate_GivenAMissingFile_ReportsPkg000WithoutThrowing() + { + var directory = NewTemporaryDirectory(); + + try + { + var report = PackageJsonValidationRunner.Run( + new PackageJsonValidationSettings(), + new BuildSettings(), + new VersionSettings(), + directory + ); + + await Assert.That(report.Succeeded).IsFalse(); + await Assert.That(report.Results[0].RuleId).IsEqualTo(PackageJsonRules.ReadFailureRuleId); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task ToJson_ExposesTheContractACallerReads() + { + var report = Validate(CompleteDotNetDocument, PackageJsonProfile.DotNetNuGet); + + using var document = JsonDocument.Parse(report.ToJson()); + var root = document.RootElement; + + await Assert.That(root.TryGetProperty("file", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("profile", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("succeeded", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("exitCode", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("errorCount", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("warningCount", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("results", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("errors", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("warnings", out _)).IsTrue(); + + await Assert.That(root.GetProperty("profile").GetString()).IsEqualTo("DotNetNuGet"); + await Assert.That(root.GetProperty("results").GetArrayLength()).IsEqualTo(PackageJsonRules.All.Count); + } + + [Test] + public async Task Rules_HaveUniquePermanentIds() + { + var ids = PackageJsonRules.All.Select(rule => rule.Id).ToArray(); + + await Assert.That(ids.Distinct(StringComparer.Ordinal)).Count().IsEqualTo(ids.Length); + await Assert.That(PackageJsonRules.IsKnown(PackageJsonRules.ReadFailureRuleId)).IsTrue(); + } + + [Test] + public async Task Profile_AutoResolvesFromTheProjectShape() + { + await Assert + .That( + PackageJsonProfiles.Resolve( + PackageJsonProfile.Auto, + ProjectType.DotNet, + packsNuGetPackages: true, + isPrivate: true + ) + ) + .IsEqualTo(PackageJsonProfile.DotNetNuGet); + + await Assert + .That( + PackageJsonProfiles.Resolve( + PackageJsonProfile.Auto, + ProjectType.DotNet, + packsNuGetPackages: false, + isPrivate: true + ) + ) + .IsEqualTo(PackageJsonProfile.DotNetSolution); + + await Assert + .That( + PackageJsonProfiles.Resolve( + PackageJsonProfile.Auto, + ProjectType.Web, + packsNuGetPackages: false, + isPrivate: true + ) + ) + .IsEqualTo(PackageJsonProfile.WebApp); + + await Assert + .That( + PackageJsonProfiles.Resolve( + PackageJsonProfile.Auto, + ProjectType.Web, + packsNuGetPackages: false, + isPrivate: false + ) + ) + .IsEqualTo(PackageJsonProfile.WebPackage); + } + + [Test] + public async Task Profile_GivenAnExplicitProfile_IsNotDerived() + { + var resolved = PackageJsonProfiles.Resolve( + PackageJsonProfile.WebApp, + ProjectType.DotNet, + packsNuGetPackages: true, + isPrivate: true + ); + + await Assert.That(resolved).IsEqualTo(PackageJsonProfile.WebApp); + await Assert.That(PackageJsonProfiles.IsWeb(resolved)).IsTrue(); + await Assert.That(PackageJsonProfiles.Publishes(resolved)).IsFalse(); + } + + [Test] + public async Task NpmNames_AcceptEveryValidShape() + { + foreach (var name in new[] { "build", "purview-build", "@purview/build", "a", "b2.c_d" }) + { + await Assert + .That(NpmNames.DescribeProblem(name)) + .IsNull() + .Because($"'{name}' is a valid npm package name."); + } + } + + [Test] + public async Task NpmNames_RejectEveryInvalidShape() + { + string[] invalid = + [ + string.Empty, + "Build", + ".hidden", + "_private", + "has space", + "node_modules", + "favicon.ico", + "a/b", + "@scope", + "@scope/", + "@/name", + "ünicode", + ]; + + foreach (var name in invalid) + { + await Assert + .That(NpmNames.DescribeProblem(name)) + .IsNotNull() + .Because($"'{name}' is not a valid npm package name."); + } + } + + static PackageJsonValidationReport Validate( + string packageJson, + PackageJsonProfile profile, + ProjectType projectType = ProjectType.DotNet, + IReadOnlyList? disabledRules = null, + bool treatWarningsAsErrors = false + ) + { + var directory = NewTemporaryDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "package.json"), packageJson); + + PackageJsonValidationSettings settings = new() + { + Enabled = true, + Profile = profile, + DisabledRules = disabledRules is null ? [] : [.. disabledRules], + TreatWarningsAsErrors = treatWarningsAsErrors, + }; + + BuildSettings build = new() { ProjectType = projectType, RunPack = true }; + + return PackageJsonValidationRunner.Run(settings, build, new VersionSettings(), directory); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + static string NewTemporaryDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), "purview-build-packagejson-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + return directory; + } +} diff --git a/src/tests/Build.UnitTests/ToolCLITests.cs b/src/tests/Build.UnitTests/ToolCLITests.cs index 1215bcb..e678726 100644 --- a/src/tests/Build.UnitTests/ToolCLITests.cs +++ b/src/tests/Build.UnitTests/ToolCLITests.cs @@ -19,6 +19,35 @@ public async Task ToolInfo_HelpTextDocumentsOptionsAndConfiguration() await Assert.That(ToolInfo.HelpText).Contains("--help"); await Assert.That(ToolInfo.HelpText).Contains("Build__RunPack=false"); await Assert.That(ToolInfo.HelpText).Contains("PURVIEW_BUILD_STACKTRACE"); + await Assert.That(ToolInfo.HelpText).Contains("release-explain"); + await Assert.That(ToolInfo.HelpText).Contains("package-validate"); + } + + [Test] + public async Task ToolCommandLine_GivenThePackageValidateVerb_SelectsTheCommand() + { + var commandLine = ToolCommandLine.Parse(["package-validate", "--format=json"]); + + await Assert.That(commandLine.Command).IsEqualTo(ToolCommand.PackageValidate); + await Assert.That(commandLine.Format).IsEqualTo(OutputFormat.Json); + } + + [Test] + public async Task ToolCommandLine_GivenTheReleaseExplainVerb_SelectsTheCommand() + { + var commandLine = ToolCommandLine.Parse(["release-explain"]); + + await Assert.That(commandLine.Command).IsEqualTo(ToolCommand.ReleaseExplain); + } + + [Test] + public async Task ToolCommandLine_GivenAVerb_KeepsItOutOfThePipelineArguments() + { + // The configuration command-line provider accepts only --key=value forms, so a bare verb + // must never reach it. + var commandLine = ToolCommandLine.Parse(["package-validate", "--Build:RunPack=false"]); + + await Assert.That(commandLine.PipelineArguments).IsEquivalentTo(["--Build:RunPack=false"]); } [Test] diff --git a/src/tests/fixtures/package-json-scenarios.json b/src/tests/fixtures/package-json-scenarios.json new file mode 100644 index 0000000..edfe85f --- /dev/null +++ b/src/tests/fixtures/package-json-scenarios.json @@ -0,0 +1,943 @@ +{ + "$comment": [ + "The single source of truth for package.json validation. Both the data-driven TUnit suite", + "(PackageJsonScenarioTests) and the `just package-matrix` recipe, which runs each case through", + "the real `purview-build package-validate` CLI, read this file. Adding a rule means appending", + "cases here, not writing test code.", + "", + "Fields: profile (PackageJson:Profile, default Auto), projectType (Build:ProjectType),", + "packsNuGetPackages (Build:RunPack), runTests/runLint, additionalRequiredFields, disabledRules,", + "treatWarningsAsErrors, packageJson (the file's contents), and the expected succeeded/exitCode/", + "profile plus the exact rule IDs that must fail and warn. 'errors' and 'warnings' are rule IDs,", + "not messages, so rewording a finding is not a fixture change." + ], + "scenarios": [ + { + "name": "dotnet-nuget-complete-passes", + "why": "A .NET repository that packs NuGet packages: the file carries the release version and full metadata.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets", "contoso"], + "private": true + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetNuGet", + "errors": [], + "warnings": [] + } + }, + { + "name": "dotnet-solution-without-packing-passes", + "why": "A .NET repository that packs nothing resolves to DotNetSolution; the same file still passes.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": false, + "packageJson": { + "name": "contoso.tools", + "version": "1.2.3", + "description": "Tooling for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/tools", + "bugs": { "url": "https://contoso.example/tools/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/tools.git" }, + "keywords": ["tooling"], + "private": true + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetSolution", + "errors": [], + "warnings": [] + } + }, + { + "name": "web-package-complete-passes", + "why": "A Web repository whose file is published to npm: everything a consumer needs is declared.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso", "url": "https://contoso.example" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebPackage", + "errors": [], + "warnings": [] + } + }, + { + "name": "web-app-complete-passes", + "why": "A Web application: private, so the publish-only rules are skipped rather than failed.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/portal", + "version": "1.0.0", + "description": "The Contoso documentation portal.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example", + "bugs": { "url": "https://contoso.example/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/portal.git" }, + "private": true, + "engines": { "node": ">=20" }, + "scripts": { + "build": "astro build", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebApp", + "errors": [], + "warnings": [] + } + }, + { + "name": "dotnet-missing-description-fails", + "why": "The description is what a registry search result shows, so its absence is an error.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "DotNetNuGet", + "errors": ["PKG003"], + "warnings": [] + } + }, + { + "name": "dotnet-weak-description-warns", + "why": "A token description is advisory: it exists, but it says nothing useful.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widgets.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetNuGet", + "errors": [], + "warnings": ["PKG003"] + } + }, + { + "name": "dotnet-missing-license-fails", + "why": "A consumer cannot act on a package that declares no licence at all.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "DotNetNuGet", + "errors": ["PKG004"], + "warnings": [] + } + }, + { + "name": "dotnet-unrecognised-license-warns", + "why": "An unlisted identifier is advisory, so a correct-but-unlisted SPDX value never fails a build.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT-ish", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetNuGet", + "errors": [], + "warnings": ["PKG004"] + } + }, + { + "name": "dotnet-missing-links-warns", + "why": "With neither a homepage nor a bug tracker, users have nowhere to read docs or report a problem.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetNuGet", + "errors": [], + "warnings": ["PKG007"] + } + }, + { + "name": "dotnet-missing-repository-fails", + "why": "A published package with no source link cannot be inspected or contributed to.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "DotNetNuGet", + "errors": ["PKG005"], + "warnings": [] + } + }, + { + "name": "dotnet-repository-shorthand-warns", + "why": "The shorthand string form omits the repository type registries link against.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": "https://contoso.example/widgets", + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetNuGet", + "errors": [], + "warnings": ["PKG005"] + } + }, + { + "name": "dotnet-missing-author-fails", + "why": "A published package needs a named maintainer.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "DotNetNuGet", + "errors": ["PKG006"], + "warnings": [] + } + }, + { + "name": "dotnet-not-private-warns", + "why": "A .NET repository's file is not an npm package, so npm terms it publishable until 'private' is set.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"] + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetNuGet", + "errors": [], + "warnings": ["PKG009"] + } + }, + { + "name": "dotnet-four-part-version-warns", + "why": "Version:Strictness=NuGet accepts four-part versions, which npm and other strict consumers reject.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3.4", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetNuGet", + "errors": [], + "warnings": ["PKG002"] + } + }, + { + "name": "dotnet-missing-version-fails", + "why": "The version is the pipeline's release version, so its absence is fatal wherever it is read.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "DotNetNuGet", + "errors": ["PKG002"], + "warnings": [] + } + }, + { + "name": "dotnet-placeholder-version-warns", + "why": "0.0.0 parses, so it is reported rather than failed — but it must not reach a release.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "packageJson": { + "name": "contoso.widgets", + "version": "0.0.0", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "DotNetNuGet", + "errors": [], + "warnings": ["PKG002"] + } + }, + { + "name": "dotnet-extra-required-field-fails", + "why": "PackageJson:AdditionalRequiredFields is the escape hatch for one organisation's own requirement.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": true, + "additionalRequiredFields": ["funding"], + "packageJson": { + "name": "contoso.widgets", + "version": "1.2.3", + "description": "Widget primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/widgets", + "bugs": { "url": "https://contoso.example/widgets/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/widgets.git" }, + "keywords": ["widgets"], + "private": true + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "DotNetNuGet", + "errors": ["PKG014"], + "warnings": [] + } + }, + { + "name": "web-package-private-fails", + "why": "A published npm package cannot be private: npm refuses to publish it. The profile is declared, since Auto derives the profile FROM the flag.", + "profile": "WebPackage", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "private": true, + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "WebPackage", + "errors": ["PKG009"], + "warnings": [] + } + }, + { + "name": "web-app-not-private-fails", + "why": "An application without 'private' would be published by a stray 'npm publish'. The profile is declared, since Auto derives the profile FROM the flag.", + "profile": "WebApp", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/portal", + "version": "1.0.0", + "description": "The Contoso documentation portal.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example", + "bugs": { "url": "https://contoso.example/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/portal.git" }, + "engines": { "node": ">=20" }, + "scripts": { + "build": "astro build", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "WebApp", + "errors": ["PKG009"], + "warnings": [] + } + }, + { + "name": "web-package-invalid-name-fails", + "why": "npm rejects an uppercase name outright, so a published package cannot ship one.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "Contoso-Core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "WebPackage", + "errors": ["PKG001"], + "warnings": [] + } + }, + { + "name": "web-app-imperfect-name-warns", + "why": "An application's name is never sent to npm, so the same defect is only advisory there.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "Contoso-Portal", + "version": "1.0.0", + "description": "The Contoso documentation portal.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example", + "bugs": { "url": "https://contoso.example/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/portal.git" }, + "private": true, + "engines": { "node": ">=20" }, + "scripts": { + "build": "astro build", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebApp", + "errors": [], + "warnings": ["PKG001"] + } + }, + { + "name": "web-package-four-part-version-fails", + "why": "npm only accepts SemVer 2.0, so a four-part version is fatal for a published package.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3.4", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "WebPackage", + "errors": ["PKG002"], + "warnings": [] + } + }, + { + "name": "web-package-missing-keywords-warns", + "why": "Keywords drive registry discovery, so a published package without them is hard to find.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebPackage", + "errors": [], + "warnings": ["PKG008"] + } + }, + { + "name": "web-package-missing-engines-fails", + "why": "A consumer installing the package needs to know which Node versions it supports.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "WebPackage", + "errors": ["PKG010"], + "warnings": [] + } + }, + { + "name": "web-app-missing-engines-warns", + "why": "An application's engine range is advisory, not something a consumer resolves against.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/portal", + "version": "1.0.0", + "description": "The Contoso documentation portal.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example", + "bugs": { "url": "https://contoso.example/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/portal.git" }, + "private": true, + "scripts": { + "build": "astro build", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebApp", + "errors": [], + "warnings": ["PKG010"] + } + }, + { + "name": "web-package-missing-entry-point-warns", + "why": "Without an entry point a consumer cannot import the published package.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "engines": { "node": ">=20" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebPackage", + "errors": [], + "warnings": ["PKG011"] + } + }, + { + "name": "web-package-missing-files-warns", + "why": "Without 'files', npm publishes everything that is not ignored, which is rarely deliberate.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebPackage", + "errors": [], + "warnings": ["PKG013"] + } + }, + { + "name": "web-package-missing-build-script-fails", + "why": "The build step runs the configured build script; without it the run fails later anyway.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "WebPackage", + "errors": ["PKG012"], + "warnings": [] + } + }, + { + "name": "web-package-missing-lint-script-warns", + "why": "The lint script is best practice rather than a requirement, so it is only named.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "keywords": ["contoso", "core"], + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebPackage", + "errors": [], + "warnings": ["PKG012"] + } + }, + { + "name": "disabled-rules-suppress-findings", + "why": "PackageJson:DisabledRules is the documented way to opt out of one rule deliberately.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "disabledRules": ["PKG008"], + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": true, + "exitCode": 0, + "profile": "WebPackage", + "errors": [], + "warnings": [] + } + }, + { + "name": "warnings-fail-when-escalated", + "why": "PackageJson:TreatWarningsAsErrors turns the same warning into a failing run.", + "profile": "Auto", + "projectType": "Web", + "packsNuGetPackages": false, + "treatWarningsAsErrors": true, + "packageJson": { + "name": "@contoso/core", + "version": "1.2.3", + "description": "Core primitives for Contoso applications.", + "license": "MIT", + "author": { "name": "Contoso" }, + "homepage": "https://contoso.example/core", + "bugs": { "url": "https://contoso.example/core/issues" }, + "repository": { "type": "git", "url": "git+https://contoso.example/core.git" }, + "engines": { "node": ">=20" }, + "exports": { ".": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "node --test", + "lint": "oxlint", + "format:check": "oxfmt --check" + } + }, + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "WebPackage", + "errors": [], + "warnings": ["PKG008"] + } + }, + { + "name": "unreadable-file-fails", + "why": "A file that cannot be read at all is reported as PKG000, in the same shape as any finding.", + "profile": "Auto", + "projectType": "DotNet", + "packsNuGetPackages": false, + "packageJson": [], + "expected": { + "succeeded": false, + "exitCode": 1, + "profile": "Auto", + "errors": ["PKG000"], + "warnings": [] + } + } + ] +} diff --git a/src/tests/scenarios/run-package-matrix.sh b/src/tests/scenarios/run-package-matrix.sh new file mode 100644 index 0000000..41e0330 --- /dev/null +++ b/src/tests/scenarios/run-package-matrix.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Runs every case in src/tests/fixtures/package-json-scenarios.json through the real CLI +# (`purview-build package-validate --format=json`) and compares the profile, the exit code and the +# exact set of failing and warning rules against the file. +# +# The same fixture drives the in-process TUnit suite, so a mismatch here means the shipped binary and +# the runner disagree. Needs no secrets and no network: the check reads one file. +set -eu + +SCENARIOS="src/tests/fixtures/package-json-scenarios.json" +WORK=".scenario-runs/package-json" + +. "$(dirname "$0")/find-tool.sh" + +rm -rf "$WORK" +mkdir -p "$WORK" + +total=0 +failed=0 + +count=$(jq '.scenarios | length' "$SCENARIOS" | tr -d '\r') +index=0 + +while [ "$index" -lt "$count" ]; do + scenario=$(jqc ".scenarios[$index]" "$SCENARIOS") + index=$((index + 1)) + total=$((total + 1)) + + name=$(printf '%s' "$scenario" | jqr '.name') + case_dir="$WORK/$name" + mkdir -p "$case_dir" + + # The file under test, verbatim: an array is deliberately unreadable, and must be reported as + # PKG000 rather than crashing the CLI. + printf '%s' "$scenario" | jq '.packageJson' > "$case_dir/package.json" + + # The configuration the scenario resolves. The project shape decides the profile when Profile is + # Auto; the remaining keys are the run's own switches. + printf '%s' "$scenario" | jq '{ + Build: ({ + ProjectType: .projectType, + RunPack: .packsNuGetPackages + } + + (if .runTests == false then { RunTests: false } else {} end) + + (if .runLint == false then { RunLint: false } else {} end)), + PackageJson: { + Enabled: true, + Profile: (.profile // "Auto"), + DisabledRules: (.disabledRules // []), + AdditionalRequiredFields: (.additionalRequiredFields // []), + TreatWarningsAsErrors: (.treatWarningsAsErrors // false) + } + }' > "$case_dir/purview-build.json" + + # The command exits non-zero when the validation fails, which is the expected outcome for most + # cases, so the report — not the exit status — is what is compared. + out=$(cd "$case_dir" && "$TOOL" package-validate --format=json 2>&1) || true + + if ! printf '%s' "$out" | jq -e '.exitCode' >/dev/null 2>&1; then + failed=$((failed + 1)) + printf 'FAIL %-52s the CLI produced no JSON report:\n%s\n' "$name" "$out" + continue + fi + + actual_profile=$(printf '%s' "$out" | jqr '.profile') + actual_exit=$(printf '%s' "$out" | jqr '.exitCode') + actual_errors=$(printf '%s' "$out" | jqc '[.results[] | select(.outcome == "fail") | .ruleId]') + actual_warnings=$(printf '%s' "$out" | jqc '[.results[] | select(.outcome == "warning") | .ruleId]') + + expected_profile=$(printf '%s' "$scenario" | jqr '.expected.profile') + expected_exit=$(printf '%s' "$scenario" | jqr '.expected.exitCode') + expected_errors=$(printf '%s' "$scenario" | jqc '.expected.errors // []') + expected_warnings=$(printf '%s' "$scenario" | jqc '.expected.warnings // []') + + if [ "$actual_profile" = "$expected_profile" ] \ + && [ "$actual_exit" = "$expected_exit" ] \ + && [ "$actual_errors" = "$expected_errors" ] \ + && [ "$actual_warnings" = "$expected_warnings" ]; then + printf 'ok %-52s %-13s exit %s\n' "$name" "$actual_profile" "$actual_exit" + else + failed=$((failed + 1)) + printf 'FAIL %-52s expected %s exit %s errors=%s warnings=%s, got %s exit %s errors=%s warnings=%s\n' \ + "$name" "$expected_profile" "$expected_exit" "$expected_errors" "$expected_warnings" \ + "$actual_profile" "$actual_exit" "$actual_errors" "$actual_warnings" + printf '%s' "$out" | jqr '.results[] | " \(.outcome) \(.ruleId) \(.message)"' + fi +done + +printf '\n%s scenario(s), %s failed\n' "$total" "$failed" + +[ "$failed" -eq 0 ] || exit 1