diff --git a/.github/skills/release-packages/SKILL.md b/.github/skills/release-packages/SKILL.md new file mode 100644 index 000000000..19f472077 --- /dev/null +++ b/.github/skills/release-packages/SKILL.md @@ -0,0 +1,115 @@ +--- +name: release-packages +description: Plan and apply deterministic Oxidizer workspace package releases. Use for targeted, changed, or all-package releases; version bumps; dependency cascades; proc-macro review; changelogs; and release validation. +license: MIT +--- + +# Release Oxidizer packages + +Create an exact release plan, verify it across independent reasoning models, and +apply it atomically. + +## Required reading + +1. Read `docs/releasing.md` for repository terminology and compatibility rules. +2. Read `references/planning.md` for classification, elevation, consensus, and + apply rules. +3. Read `references/version-rules.md` before reviewing a plan. + +## Deterministic helpers + +Run these from the repository root: + +- `.github/skills/release-packages/scripts/release-facts.ps1` gathers the + workspace graph, release baselines, + modification state, public type exposure, macro publication, implementation + closures, and generated-runtime relationships. +- `.github/skills/release-packages/scripts/resolve-plan.ps1` performs token + parsing, version arithmetic, pins, type- and macro-contract-aware cascades, + ambiguity reporting, and topological ordering. +- `.github/skills/release-packages/scripts/apply-plan.ps1` performs version + writes, changelog and README generation, validation, and rollback. +- `.github/skills/release-packages/scripts/release-changelog.ps1` writes one + deterministic changelog. + +Never reproduce their work by hand. + +## Workflow + +1. **Preflight** + - Require PowerShell 7, Cargo, `cargo semver-checks`, and a clean baseline. + - Record `git rev-parse HEAD` and `git status --porcelain`. + +2. **Gather facts** + - Run `.github/skills/release-packages/scripts/release-facts.ps1` and save its + JSON. + - Determine targeted, changed, or all mode. + +3. **Classify** + - For every ordinary, previously released library that may enter the plan, + run `cargo semver-checks` against its `baselineSha`. + - Review each affected proc-macro contract across its + `macroImplementationClosure` and `macroRuntimePartners`. + - Record a `macroContracts` attestation covering exported macros, accepted + syntax, compile behavior, generated API, runtime paths, and hygiene. + - Measure every fixture the facts list in `macroCompileFixtureChanges` by + compiling it at `baselineRev` and at the current revision, and record each + result in `macroContracts..compileEvidence`. The resolver derives + the verdict floor from those measurements and blocks a weaker verdict. + - Retain `manualReview: true`. + - Review every candidate according to the deterministic selection table in + `references/planning.md`. Record an evidenced `selectionDecisions` entry + for every candidate; never omit a package because its changes look + mechanical. + - For a `behavior-fix` reason, measure a consumer-runtime, consumer-compile, + or packaged-artifact probe at the release baseline and at the current + revision, and record both runs in + `selectionDecisions..regressionEvidence`. Only a baseline failure + that now passes demonstrates the fix; a preserved-behavior refactor is + `internal-only`. + - Check `externalDepChanges` against `externalExposedDeps`. A breaking + external requirement change on a publicly exposed dependency forces a + `breaking` classification and a `breaking` selection reason; the resolver + blocks anything weaker. Private external dependencies and proc-macro-only + packages are unaffected. + - Classify implemented source and verified consumer behavior. TODOs, design + notes, and roadmap text are not compatibility evidence. + - If resolution reports missing classifications, classify the complete + dependent closure it names and rerun with the same frozen facts. + - Invoke the resolver with the complete decision map even when every + candidate is declined; the canonical result is an empty resolved plan. + +4. **Resolve mechanically** + - Write request JSON containing `mode`, accepted `tokens`, + `selectionDecisions`, `classifications`, required `macroContracts`, and + optional `force`. + - Run `.github/skills/release-packages/scripts/resolve-plan.ps1 -FactsPath + -RequestPath `. + - Treat its release set, versions, cascade reasons, and ordering as canonical. + - If it returns `status: blocked`, review every package named in + `ambiguities` and rerun with the same frozen facts. Never convert an + unresolved macro contract into a conservative breaking guess. + - In changed or all mode, do not apply an empty resolved plan. + +5. **Consensus** + - Freeze the facts, classifications, reviewed evidence, request, and resolved + plan. + - Ask at least two additional model families to review the classifications + and verify that the resolved plan follows from them. + - Do not ask models to independently redo version arithmetic or cascade + resolution. + - Stop with the structured ambiguity report from `references/planning.md` if + classifications or rules diverge. + +6. **Apply atomically** + - Run `.github/skills/release-packages/scripts/apply-plan.ps1 -PlanPath + `. + - Never reproduce its writes or rollback behavior by hand. + +7. **Report** + - Emit the canonical JSON plan and a concise table. + - Include manual-review flags, warnings, and consensus status. + - Copy normalized release fields and resolver warnings directly from + `plan.json`; keep environment or methodology notes separate. + +Do not publish packages unless the user explicitly requests publication. diff --git a/.github/skills/release-packages/references/planning.md b/.github/skills/release-packages/references/planning.md new file mode 100644 index 000000000..ac36ae313 --- /dev/null +++ b/.github/skills/release-packages/references/planning.md @@ -0,0 +1,690 @@ +# Release planning + +This reference contains the model-owned parts of an Oxidizer release. Mechanical +token parsing, version arithmetic, dependency closure, pin reconciliation, and +topological ordering belong to +`.github/skills/release-packages/scripts/resolve-plan.ps1`. + +## Inputs + +Use one mode: + +- **targeted**: explicit package tokens. +- **changed**: review every published package with unreleased modifications. +- **all**: review every published package. + +A token is `name`, `name@breaking`, `name@nonbreaking`, `name@patch`, or +`name@`. Change types are lower bounds. A version is an exact pin and +must be strictly greater than the current version. + +## Facts + +Run: + +```powershell +./.github/skills/release-packages/scripts/release-facts.ps1 > facts.json +``` + +Facts use `schemaVersion: 5`; regenerate them when the release tooling changes. +The resolver rejects stale or incomplete facts rather than silently skipping +macro-contract checks. Each package fact contains: + +`folder, name, version, published, procMacroOnly, hasLibraryTarget, deps, +exposedDeps, macroPublicDeps, macroImplementationClosure, +macroRuntimePartners, macroCompileFixtureChanges, externalDepChanges, +externalExposedDeps, exposureUnknown, baselineSha, +hasBaseline, everReleased, modified, modifiedFiles, modifiedFileCount, +manifestDependencyScopes, manifestOtherChanged, rustImplementationChanged, +docCommentChanged, workspaceModified`. + +- `deps` contains normalized non-dev dependency names. +- `exposedDeps` contains direct or transitively reachable workspace packages + whose defining types appear in the package's public API. Fact gathering + resolves dependency aliases and custom `[lib] name` crate roots. +- `macroPublicDeps` contains proc-macro packages whose entry points are + positively identified by a concrete root in the package's public allowlist. + Wildcards are not positive publication evidence. These are behavioral + contracts, not Rust type-identity exposure. +- `macroImplementationClosure` contains workspace dependencies reachable from a + proc-macro package. It defines the source-diff review scope. +- `workspaceModified` includes unpublished workspace packages. `modified` + remains publishable-only for release selection, while proc-macro review uses + the broader fact so private implementation helpers cannot bypass attestation. +- `modifiedFiles` is the sorted, repo-relative baseline diff used to audit and + mechanically constrain selection reasons. +- `manifestDependencyScopes` mechanically identifies changed `normal`, `build`, + and `dev` dependency declarations plus package `features`. The resolver rejects a + `runtime-manifest-change` without a normal/build dependency or package-feature + change and rejects + `dev-dependency-only` when authored files or runtime dependencies also changed. +- `manifestOtherChanged` records a semantic change elsewhere in `Cargo.toml`, + excluding lints and `[package.metadata]`. This distinguishes mixed manifest + edits from a pure dev-dependency change without deciding whether the other + edit itself requires a release. +- `rustImplementationChanged` is true only when the crate's own packaged Rust + source changed beyond doc comments -- an added or removed non-comment line in a + `.rs` file under `src/`, a custom `[lib]` path, or `build.rs`, never under + `tests/`, `benches/`, or `examples/`. Doc-comment, test, benchmark, README, + changelog, and manifest edits leave it false. A previously released ordinary + library with this false and no exposed breaking external dependency change has + no own-diff basis for a `breaking` or `nonbreaking` classification; any + elevation above patch must come from a resolver-owned cascade, not from a + re-exported macro contract or a dependency bump read into the crate's own diff. + It fails safe: a missing baseline or a brand-new untracked source file counts + as an implementation change. +- `docCommentChanged` is true only when a rustdoc-visible doc comment (`///` or + `//!`) was added or removed in a doc-eligible source file (`src/` or a custom + `[lib]` path, never `build.rs`, `tests/`, `benches/`, or `examples/`). It + positively identifies a consumer-visible documentation change, so a plain `//` + comment or a whitespace reflow -- which also leave `rustImplementationChanged` + false -- do not force a release. With `rustImplementationChanged` false and no + runtime-manifest or exposed breaking external dependency change, it makes the + crate's own diff an `authored-doc-fix`. +- `macroRuntimePartners` is inferred by reversing `macroPublicDeps`: every + package that publicly exposes a proc macro becomes its runtime façade. + `[package.metadata.oxidizer_release].macro_runtime` is an optional escape + hatch for generated relationships without a public façade edge. + A macro attestation that marks `generatedRuntimePaths` as changed blocks + resolution when no partner was inferred or declared, preventing a new macro + crate from silently omitting the relationship. +- `macroCompileFixtureChanges` lists, for each proc-macro package, every + compile-fixture path that changed in its review scope: `tests/ui/**` and + `tests/compile_fail/**` `.rs` cases plus their `.stderr`/`.stdout` + expectations, gathered from the macro itself, its modified + `macroImplementationClosure`, and its modified `macroRuntimePartners`. This + crosses package boundaries on purpose: a fixture that proves a macro now + rejects previously accepted input usually lives in the runtime façade, where + it is otherwise indistinguishable from an ordinary test-only edit. Each item + carries `ownerPackage`, `ownerPublished`, `path`, `kind` + (`uiFixture`/`uiExpectation`), `status` (`added`/`modified`/`removed`), + `expectedResult` (`fail` when a recorded expectation exists on either side, + otherwise null), `baselineRev` (the revision the diff was taken against), and + `scopeRole` (`self`, `runtimePartner`, or `implementationClosure`). Items are + sorted ordinally by owner then path. +- `externalDepChanges` lists every effective non-dev **external** (registry) + dependency requirement that differs between the package's release baseline + and the working tree, with `name`, `baselineReq`, `currentReq`, `kinds` + (`normal`/`build`), `breaking`, and `baselineRev`, sorted ordinally by name. + Requirements are compared after cargo's own normalization, and + `[workspace.dependencies]` inheritance is resolved on both sides, so a root + `Cargo.toml` bump is attributed to every crate that inherits it. + `breaking` is true when the requirement leaves the Cargo compatibility line + it was released against (`^2.0.111` to `^3.0.2`, `^0.5.1` to `^0.6.0`), when + the dependency was dropped, or when either requirement cannot be read well + enough to decide. A move within one line (`^2.0.111` to `^2.9.0`) and a newly + added dependency are not breaking. + A package whose only change is an inherited requirement is promoted to + `modified`/`workspaceModified` with the affected scope added to + `manifestDependencyScopes`: `cargo publish` inlines the inherited value, so + its published manifest really did change even though no file under + `crates//` was touched. +- `externalExposedDeps` lists the current external dependencies whose types the + package's public API may name, derived from + `[package.metadata.cargo_check_external_types].allowed_external_types` with + the same fail-closed rules as `exposedDeps`. It is always empty for + proc-macro-only packages: a macro exports behavior, and rustc keeps foreign + type identity from crossing the macro boundary. +- `exposureUnknown` remains true for unchecked non-library targets. Proc-macro + packages set it false because rustc prevents dependency types from crossing + a proc-macro boundary. +- For ordinary libraries, missing metadata fails closed on direct dependencies; + an explicit empty allowlist proves no direct exposure. Indirect exposure + requires positive allowlist evidence. +- Use `everReleased`, not `hasBaseline`, to identify a first release. A crate's + introducing commit also counts as a version-bump baseline. + +Never hand-parse Cargo metadata or reconstruct these facts. + +## Objective classification + +Build a classification map for every package that may enter the release set. + +### Previously released ordinary libraries + +Run: + +```text +cargo semver-checks --package --baseline-rev \ + --all-features --color never +``` + +Map the result: + +| Result | Classification | +|---|---| +| major bump required | `breaking` | +| minor bump required | `nonbreaking` | +| compatible / no update required | `patch` | + +Tool or build failures are fatal. Never silently classify them as patch. +`cargo semver-checks` proves compatibility but may not identify a new public API +as requiring a minor bump. Source-diff review must elevate such additions to +`nonbreaking`. + +### First-ever releases + +When `everReleased = false`, do not run `cargo semver-checks` against the +introducing commit. The first release uses the version already declared in +`Cargo.toml`. + +### Proc-macro-only packages + +Implementation dependency versions are not the proc-macro contract. Review the +proc-macro package, modified members of its `macroImplementationClosure`, and +affected `macroRuntimePartners`. The consumer contract includes: + +- exported macro names; +- derive helper attributes; +- accepted syntax and compile success/failure; +- generated behavior, public API, bounds, and implementations; +- generated runtime paths and requirements; +- hygiene and name resolution. + +Diagnostic wording and span changes are patch unless documented as contractual. +Changing accepted input into a compile failure is breaking. Token formatting is +not a contract; judge behavior-equivalent, not byte-equivalent, expansion. + +Record `macroContracts.` with: + +- `verdict`: `compatible`, `nonbreaking`, or `breaking`; +- `reviewedPackages`: at least every package in the resolver's review scope + (self plus each modified implementation-closure member and modified runtime + partner). Reviewing more is allowed, but the plan's emitted `reviewed` field is + the resolver-computed scope, so unmodified extras never affect the output; +- all required contract channels classified as `unchanged`, `changed`, or + `notApplicable`; +- concrete evidence such as normalized expansion snapshots, trybuild pass/fail + fixtures, generated-runtime compile tests, and exported entry-point review; +- `compileEvidence`: one measured entry per fixture in + `macroCompileFixtureChanges`. + +### Compile evidence + +Every fixture the facts report is an obligation the contract must discharge. +Each `compileEvidence` entry is: + +```json +{ + "ownerPackage": "ohno", + "path": "crates/ohno/tests/ui/ohno_error_no_constructors.rs", + "baseline": { "result": "pass", "revision": "", "exitCode": 0 }, + "current": { "result": "fail", "revision": "", "exitCode": 101 } +} +``` + +Measure each fixture by compiling it at both revisions; `result` is `pass` or +`fail` and must be accompanied by the revision measured and the compiler exit +code. A `.stderr`/`.stdout` obligation is discharged by measuring its `.rs` +sibling, so one measurement covers the whole fixture group. + +The resolver reads those two outcomes mechanically: + +| Baseline | Current | Derived verdict floor | +|---|---|---| +| pass | fail | `breaking` | +| fail | pass | `nonbreaking` | +| pass | pass | `compatible` | +| fail | fail | `compatible` | + +The declared `verdict` may sit at or above the strongest derived floor, never +below it. Fixtures owned by a *published* member of `macroImplementationClosure` +are still reported and must still be measured, but do not set the floor: that +crate carries its own independent classification. + +The verdict is the objective classification for the proc-macro contract. +`manualReview` always remains true. A published implementation library still +receives its own independent Rust API classification; `#[doc(hidden)]` does not +remove its SemVer obligations. + +### External dependency exposure + +A crate's external dependency requirements are part of its published manifest, +so a consumer resolves against them directly. Moving one to another +compatibility line while the crate's public API names that dependency's types +hands every consumer a different type identity under unchanged paths -- +invisibly to `cargo semver-checks`, which only sees this workspace's rustdoc. + +The resolver derives the floor mechanically, with no judgement to record: + +| `externalDepChanges` entry | In `externalExposedDeps` | Derived floor | +|---|---|---| +| `breaking: true` | yes | `breaking` | +| `breaking: true` | no (private dependency) | none | +| `breaking: false` | either | none | +| any (proc-macro-only package) | never (always empty) | none | + +A classification below that floor blocks with `externalExposureUnderclassified`; +a selection reason other than `breaking` -- including any decline -- blocks with +`externalExposureUnderselected`. Both list the dependencies and both +requirements, and neither can be argued away: raise the classification and the +reason, or revert the requirement. Crates with `everReleased: false` are exempt, +because a first release has no prior requirement to invalidate. + +## Selecting packages + +Snapshot published, modified packages before resolving any cascade. + +- **targeted**: explicit tokens are accepted. Review every other package in the + modified snapshot. +- **changed**: review the modified snapshot and accept packages with + consumer-visible changes. +- **all**: review every published package. An unchanged package may be released + only with an explicit token and an `explicit-release` decision. + +For a reviewed package: + +1. Inspect `git diff ..HEAD -- crates/` plus working-tree + changes. +2. Default to the objective classification. +3. Elevate only with concrete evidence the tool cannot see: + - documented behavioral incompatibility -> breaking; + - missed public signature or type incompatibility -> breaking; + - a major dependency upgrade used in exposed public types -> breaking; + - narrowed generic or auto-trait implementation bounds -> breaking; + - missed backward-compatible public addition -> nonbreaking. +4. Treat packaged documentation repairs that fix broken links or incorrect + consumer guidance as patch changes. +5. Decline packages with no consumer-visible change. Opaque generated README + metadata and dependency-version link refreshes do not seed a release when + they are only byproducts of another package's planned release. + +Never elevate by taste. Cite the file and public item or behavior. +If every changed or all candidate is declined, stop with an empty plan and do +not apply it. + +### Deterministic selection decisions + +In `changed` mode, record exactly one `selectionDecisions` entry for every +published package where `modified = true`. In `all` mode, record one for every +published package. The resolver rejects missing decisions, decline decisions +that have tokens, accept decisions without tokens, aliases, and extra keys. Use +the canonical `folder` identifier as each key. Invoke the resolver even when all +candidates are declined so it can validate and emit the canonical empty plan. + +Judge only the package's own diff when selecting it. Dependency pickup, +exposure, macro-public, and runtime-partner effects belong to the resolver. +Decline a package with no release-worthy own diff even when it will later appear +in the plan with `source: cascade`. +Accordingly, a `breaking` selection reason must agree with that package's own +objective classification. A runtime facade must not label itself breaking only +because a re-exported macro contract breaks; the resolver applies that cascade. +Such a mismatch blocks as `breakingSelectionUnderclassified`. + +When a package's own authored packaged source changed such that a +rustdoc-visible doc comment (`///` or `//!`) was added or removed +(`docCommentChanged`) while `rustImplementationChanged` is false, the crate's +own diff is documentation only. Doc comments ship in rustdoc and are +consumer-visible, so with no runtime-manifest change and no exposed breaking +external dependency the one canonical outcome is accept `authored-doc-fix`. The +resolver rejects declining such a change as `internal-only` (the documentation +did change) or any other reason. A plain `//` comment or whitespace edit leaves +`docCommentChanged` false and stays eligible for `internal-only`. A published +normal/build/features manifest change takes precedence over a doc tweak: a +package with both uses `runtime-manifest-change`, not `authored-doc-fix`. + +Use the first matching rule: + +| Change evidence | Decision | Reason | +|---|---|---| +| Compatibility break in public API, documented behavior, or macro compile contract | accept | `breaking` | +| Backward-compatible public API addition | accept | `nonbreaking-api` | +| Consumer-observable behavior or packaging fix | accept | `behavior-fix` | +| Repair to authored, packaged docs or Rust doc comments | accept | `authored-doc-fix` | +| Normal/build dependency declaration or feature activation changed | accept patch | `runtime-manifest-change` | +| First release with release-worthy packaged content since introduction | accept | `first-release` | +| Unchanged package explicitly requested in `all` mode | accept | `explicit-release` | +| Tests or test-support source plus supporting dev-dependency edits only | decline | `test-only` | +| Benchmarks plus supporting dev-dependency edits only | decline | `benchmark-only` | +| Dev-dependency declaration only | decline | `dev-dependency-only` | +| Lints, docs.rs, `cargo_check_external_types`, release metadata, or formatting only | decline | `release-metadata-only` | +| Generated crate README or generated changelog only | decline | `generated-artifact-only` | +| Internal refactor with proven unchanged observable behavior | decline | `internal-only` | +| No diff from the relevant baseline | decline | `unchanged` | + +Crate `README.md` files are generated by `just readme`; never use their diff as +release evidence. Review their originating Rust doc comments instead. +Changelogs are release-generated and likewise never seed a later release. +A normal/build dependency declaration or package-feature change is part of the published manifest and +always seeds a patch, even when it only fixes compilation under a workspace +feature configuration. Dev-dependency features do not. +Use `manifestDependencyScopes` as the authority for dependency scope rather than +inferring it from a `Cargo.toml` path or prose evidence. +The resolver requires normal/build dependency and package-feature changes to be +accepted, and gives a pure dev-dependency-only manifest edit exactly one valid +outcome: decline with `dev-dependency-only`. +A package promoted solely by an inherited `[workspace.dependencies]` change +carries no `modifiedFiles`, so read `externalDepChanges` for its diff: when the +change is not in `externalExposedDeps`, `runtime-manifest-change` is the reason; +when it is, only `breaking` is accepted. + +When several non-release categories are mixed, ignore generated artifacts and +release metadata while classifying the remaining diff. Use `test-only` or +`benchmark-only` when their support edits include dev dependencies; otherwise +use `dev-dependency-only`. If nothing remains, use `release-metadata-only` when +metadata changed, or `generated-artifact-only` when only generated files +changed. Metadata takes precedence: `generated-artifact-only` is valid only when +the sole changed files are a generated `README.md` or `CHANGELOG.md`. The +resolver rejects it when a `Cargo.toml` (or any other path) also changed, so a +lint or metadata edit beside a regenerated README is `release-metadata-only`. +Never accept a first release merely because `everReleased = false`; +its own diff must contain release-worthy packaged content. +The resolver rejects `first-release` when every changed path is under `tests/` +or `benches/`, is outside the package allowlist, or is only Cargo/release +metadata or a generated README/changelog. A never-released accepted package must +use the `first-release` reason. + +### Behavior-fix evidence + +`behavior-fix` is the only accepted reason that asserts observable behavior +changed, so the resolver requires it to be demonstrated rather than described. +Record `regressionEvidence` on the decision: one entry per probe, measured at +the release baseline and at the current revision. + +```json +"regressionEvidence": [ + { + "kind": "consumer-runtime", + "probe": "cargo test -p cachet_tier --test eviction", + "baseline": { "result": "fail", "revision": "", "exitCode": 101 }, + "current": { "result": "pass", "revision": "", "exitCode": 0 } + } +] +``` + +`kind` is `consumer-runtime` (behavior a consumer observes at run time), +`consumer-compile` (a consumer fixture that must build), or `packaged-artifact` +(what the published `.crate` contains). Both sides must name the revision +measured, a `pass`/`fail` result, and the process exit code; `pass` pairs with +exit code 0 and `fail` with a non-zero code. + +Only `fail` then `pass` on the same probe demonstrates a fix. Everything else +blocks the plan with zero releases: + +| Observation | Ambiguity | +|---|---| +| No `regressionEvidence` recorded | `behaviorFixUndemonstrated` | +| pass then pass (behavior preserved) | `behaviorFixUndemonstrated` | +| fail then fail (still broken) | `behaviorFixUndemonstrated` | +| pass then fail (newly broken) | `behaviorFixUndemonstrated` | +| Missing result, revision, or exit code | `behaviorEvidenceInconclusive` | +| Exit code contradicts the result | `behaviorEvidenceInconclusive` | +| Both sides measured the same revision | `behaviorEvidenceInconclusive` | + +Additional probes that did not move are allowed as long as one probe +demonstrates the fix. A refactor that preserves behavior cannot produce that +measurement, which is the point: classify it `internal-only` and decline. +Malformed entries -- prose instead of an object, a blank `probe`, an unknown +`kind` -- are rejected outright. Other reasons are unaffected; +`regressionEvidence` is ignored everywhere else. + +For proc macros, "compile behavior changed" means the end-to-end result changed +for a representative consumer fixture. Parser acceptance alone is not the +contract. Compile the same fixture against the baseline and current package: + +- baseline passes, current fails -> breaking; +- baseline fails, current passes -> nonbreaking behavior fix; +- both fail for the same invalid input -> compatible patch unless documented + diagnostic behavior changed incompatibly; +- both pass -> judge expansion API, behavior, runtime paths, and hygiene. + +Do not infer that an old invocation failed merely because its generated code +looks difficult to construct. Record the actual baseline/current command and +exit result in macro evidence. A tool failure or fixture whose prior support +status cannot be established is inconclusive and blocks the plan. + +Classify implemented API, not aspirations in TODO, design, or roadmap files. +Such documents can direct investigation but cannot prove a compatibility break. +When `cargo-semver-checks` passes and an `impl Trait` return is replaced by a +newly public concrete type that implements the same trait, treat the new named +type and its additive methods as `nonbreaking` only when it preserves the prior +opaque type's trait, auto-trait (`Send`, `Sync`, `Unpin`), and lifetime-capture +guarantees. Verify uncertain bounds with baseline/current consumer fixtures. + +## Resolve the plan + +Write request JSON: + +```json +{ + "mode": "targeted", + "tokens": ["bytesbuf@breaking"], + "selectionDecisions": {}, + "classifications": { + "bytesbuf": "patch", + "bytesbuf_io": { + "changeType": "patch", + "manualReview": false + } + }, + "macroContracts": { + "templated_uri_macros": { + "verdict": "compatible", + "reviewedPackages": [ + "templated_uri_macros", + "templated_uri_macros_impl" + ], + "channels": { + "exportedMacros": "unchanged", + "acceptedSyntax": "unchanged", + "compileBehavior": "unchanged", + "generatedApi": "unchanged", + "generatedRuntimePaths": "unchanged", + "hygiene": "unchanged" + }, + "evidence": [ + "Normalized expansion snapshots and compile fixtures are unchanged." + ], + "compileEvidence": [ + { + "ownerPackage": "templated_uri", + "path": "crates/templated_uri/tests/ui/bad_placeholder.rs", + "baseline": { + "result": "fail", + "revision": "7c185b447c5c1c94db36c6176d42093aa67b83a2", + "exitCode": 101 + }, + "current": { + "result": "fail", + "revision": "HEAD", + "exitCode": 101 + } + } + ] + } + }, + "force": false +} +``` + +Include classifications for every previously released ordinary library reachable +from accepted tokens. The resolver fails rather than guessing a missing +classification; classify the complete dependent closure named by the failure and +rerun against the same facts. + +Run: + +```powershell +./.github/skills/release-packages/scripts/resolve-plan.ps1 ` + -FactsPath facts.json -RequestPath request.json > plan.json +``` + +The resolver guarantees: + +- complete, token-consistent candidate selection in changed/all mode; +- strict SemVer token and pin validation; +- the version rules in `version-rules.md`; +- transitive published-dependent closure; +- exclusion of never-published dependents from cascades; +- patch floor for every dependency pickup; +- breaking propagation through ordinary type exposure and reviewed public macro + contracts; +- structured blocking when a required macro contract is absent or incomplete; +- structured blocking when a changed compile fixture in a macro's review scope + is unmeasured, inconclusive, or contradicts the declared verdict; +- structured blocking when a `behavior-fix` selection reason is not demonstrated + by a probe that failed at the baseline and passes now; +- structured blocking when a breaking external dependency requirement change + reaches a dependency the package's public API exposes; +- fixed-point propagation through chains and diamonds; +- proc-macro manual-review preservation; +- pin-versus-requirement rejection unless `force` is true; +- dependency-before-dependent ordering; +- deterministic, sorted cascade reasons. + +The resolver has three independent cascade lanes: + +1. Every direct Cargo dependency release gives a patch floor. +2. An ordinary type edge is breaking when the dependency's version transition + is Cargo-incompatible and the dependent lists it in `exposedDeps`, or is a + direct dependent with `exposureUnknown = true`. +3. A macro implementation edge never breaks automatically. Its reviewed + `macroContracts` verdict classifies the proc macro. A proc-macro dependency + breaks a dependent only when the contract verdict is breaking and the + dependent lists it in `macroPublicDeps`. + +The declared verdict is a checked assertion, not a declaration. The resolver +derives a floor from `compileEvidence` and blocks any verdict below it with +`macroVerdictUnderclassified`, reporting `declaredVerdict`, `derivedVerdict`, +and the `decidingFixtures`. A derived floor above `compatible` is also coupled +to selection: a package whose fixtures prove a break cannot be declined, and its +selection reason must be `breaking` (or, for a nonbreaking floor, one of +`breaking`, `nonbreaking-api`, `behavior-fix`). Every resolved plan echoes the +`derivedVerdict` next to the declared `verdict` in `macroContracts`. + +A compatible proc-macro contract stays at the patch floor even when its +implementation dependency or exact pinned version crosses a Cargo major line. +An unresolved required contract returns `status: blocked` with sorted +`ambiguities`; no partial release plan may be applied. The compile-evidence +ambiguity kinds are: + +| Kind | Meaning | +|---|---| +| `macroCompileFixtureUnevidenced` | A changed fixture has no `compileEvidence` entry | +| `macroCompileEvidenceInconclusive` | An entry lacks a pass/fail result, revision, or exit code | +| `macroVerdictUnderclassified` | The declared verdict is weaker than the derived floor | + +Selection evidence blocks the same way. `behaviorFixUndemonstrated` means no +probe moved from failing to passing; `behaviorEvidenceInconclusive` means a +probe's measurement cannot be read or contradicts itself. Both name the missing +`requiredInput` and emit zero releases. + +External exposure blocks the same way, without any recorded evidence to weigh: + +| Kind | Meaning | +|---|---| +| `externalExposureUnderclassified` | A classification is weaker than the floor a breaking exposed external dependency change forces | +| `externalExposureUnderselected` | The selection reason for such a package is not `breaking` | +| `breakingSelectionUnderclassified` | A package selected as breaking has a weaker own objective classification | +| `ownClassificationUnsupported` | A previously released library is classified breaking/nonbreaking though only its doc comments, tests, or manifest changed and no exposed external dependency break forces it | + +When a breaking runtime release matches a proc macro's +`macroRuntimePartners`, the resolver requires a macro-contract review. A +compatible verdict records the review without forcing a macro release; a +nonbreaking or breaking verdict adds the macro to the release set. + +`force` never permits a downgrade or a pin equal to the current version. When it +keeps a pin below a computed requirement, the resolver records a warning and +retains the stronger effective change type for further cascades. + +## Consensus gate + +Before writing repository files: + +1. Freeze facts, tokens, classifications, reviewed diffs, and `plan.json`. +2. Ask at least two additional model families to independently review the + classifications and verify the plan follows from them. +3. Compare normalized tuples: + `folder, to, changeType, source, manualReview, contractBreaking, + cascadeReasons`, plus top-level selection decisions and macro attestations. + Copy these tuple fields from `plan.json`; do not restate or override + `manualReview`, warnings, or change types in a separate result summary. + `manualReview` is resolver-owned: true for proc-macro-only packages and false + otherwise. Omit it from classifications, or supply only the matching value. +4. Continue only on unanimous agreement. + +On divergence, stop and report: + +- package and rule that diverged; +- each model's decision and evidence; +- the ambiguous sentence; +- a concrete proposed edit to this skill; +- recommended human action. + +Do not average, vote, or choose the most conservative plan silently. +The resolver is the canonical authority for arithmetic, cascades, pins, and +ordering. Models verify its inputs and output; they do not replace it with +hand-computed plans. + +## Apply atomically + +Run: + +```powershell +./.github/skills/release-packages/scripts/apply-plan.ps1 -PlanPath plan.json +``` + +The helper edits only package and workspace dependency version values, generates +changelogs and READMEs, validates with Cargo, verifies applied versions, and +restores every file it touched if any operation fails. + +## Canonical output + +```json +{ + "status": "resolved", + "mode": "targeted", + "selectionDecisions": [], + "releases": [ + { + "folder": "bytesbuf", + "name": "bytesbuf", + "from": "0.8.0", + "to": "0.9.0", + "changeType": "breaking", + "source": "user", + "manualReview": false, + "contractBreaking": false, + "cascadeReasons": [] + } + ], + "macroContracts": [], + "ambiguities": [], + "warnings": [], + "consensus": { + "models": ["model-a", "model-b", "model-c"], + "agreement": "unanimous" + } +} +``` + +Each `macroContracts` entry echoes the reviewed `verdict` alongside the +`derivedVerdict` the resolver computed from `compileEvidence`: + +```json +{ + "package": "ohno_macros", + "verdict": "breaking", + "derivedVerdict": "breaking", + "reviewed": ["ohno", "ohno_macros"], + "evidence": ["#[no_constructors] under #[ohno::error] is now rejected."] +} +``` + +Each `selectionDecisions` entry echoes the graded probes so the consensus review +compares measurements, not prose: + +```json +{ + "package": "cachet_tier", + "decision": "accept", + "reason": "behavior-fix", + "evidence": ["Eviction now honors the configured tier bound."], + "regressionEvidence": [ + { + "kind": "consumer-runtime", + "probe": "cargo test -p cachet_tier --test eviction", + "outcome": "fail->pass" + } + ] +} +``` diff --git a/.github/skills/release-packages/references/scenarios.md b/.github/skills/release-packages/references/scenarios.md new file mode 100644 index 000000000..be7257c52 --- /dev/null +++ b/.github/skills/release-packages/references/scenarios.md @@ -0,0 +1,42 @@ +# Scenario coverage + +Executable scenarios live in +`scripts/tests/Pester/unit/releasing/ReleasePlan.Tests.ps1`. + +The matrix covers: + +| Area | Cases | +|---|---| +| Version lines | stable, `0.x`, `0.0.x` | +| Change types | breaking, nonbreaking, patch | +| Package state | previously released, first release, test-only first-release rejection, unpublished | +| Graphs | single, linear, diamond, duplicate normal/build edge, transitive exposure | +| Exposure | exposed, encapsulated, wildcard/unknown, empty/missing, stale roots | +| Proc-macros | implementation dependency, compatible/breaking contract, public/private use, major pin, generated runtime, blocked review | +| Compile evidence | partner-owned fixtures, pass→fail/fail→pass/unchanged floors, unmeasured and inconclusive blocks, sibling expectation discharge, selection-reason coupling, published-dependency fixtures | +| Regression evidence | fail→pass release, missing/pass→pass/fail→fail/pass→fail blocks, all three probe kinds, incomplete and self-contradicting measurements, single-revision probes, malformed entries, unaffected reasons | +| External dependencies | compatibility-line breaks, in-line bumps, exposed/private/proc-macro-only exposure, workspace-inherited candidate promotion, unknown exposure and unparsable requirements failing closed, classification and selection floors | +| Pins | valid, first release, build metadata, equal/downgrade rejection, satisfied cascade, conflict, force | +| Modes | targeted, changed, all, complete selection decisions, token consistency | +| Output | topological order, merged reasons, breaking flags, warnings | +| Changelogs | maintenance, breaking, multiple sorted reasons | +| Cargo APIs | internal edit, addition, removal, signatures, fields, traits, enums | +| Atomic apply | exact version edits, validation, rollback | + +The test matrix is the hard oracle for mechanical behavior. Diff interpretation, +proc-macro semantics, and evidence-based elevation remain judgment-dependent and +must pass the multi-model consensus gate. + +Selection review also covers generated README/changelog exclusion, +test/benchmark/dev-dependency-only declines, runtime dependency-feature patch +seeds, and baseline-pass/current-fail proc-macro fixtures. Fixture coverage is +mechanical: `release-facts.ps1` enumerates the changed compile fixtures in each +macro's review scope, and the resolver refuses any verdict weaker than the +outcomes measured for them. Selection reasons are held to the same standard: a +`behavior-fix` accept must exhibit a probe that failed at the release baseline +and passes now, so an internal adaptation cannot seed a release by being +described as a fix. External dependency requirements are read from cargo's own +resolved metadata on the current side and from the baseline manifests plus +`[workspace.dependencies]` on the other, so an inherited root bump is attributed +to every crate that inherits it and cannot be released as a patch while the +crate's public API exposes that dependency's types. diff --git a/.github/skills/release-packages/references/version-rules.md b/.github/skills/release-packages/references/version-rules.md new file mode 100644 index 000000000..56aaa043d --- /dev/null +++ b/.github/skills/release-packages/references/version-rules.md @@ -0,0 +1,31 @@ +# Version rules + +Change-type strength is: + +```text +none < patch < nonbreaking < breaking +``` + +Given `major.minor.patch`: + +| Current version | breaking | nonbreaking | patch | +|---|---|---|---| +| `x.y.z`, `x >= 1` | `(x+1).0.0` | `x.(y+1).0` | `x.y.(z+1)` | +| `0.y.z`, `y >= 1` | `0.(y+1).0` | `0.y.(z+1)` | `0.y.(z+1)` | +| `0.0.z` | `0.0.(z+1)` | `0.0.(z+1)` | `0.0.(z+1)` | + +These rules are intentional: + +- On `0.y.z`, nonbreaking and patch produce the same number but retain distinct + classifications. +- Every `0.0.z` transition is breaking under Cargo compatibility. Across an + ordinary Rust type-exposure edge, even a patch-classified `0.0.z` release + gives the dependent a breaking cascade floor. +- Proc-macro edges propagate reviewed macro-contract impact, not Cargo version + compatibility. A contract-compatible `0.0.z` proc-macro release therefore + gives a direct dependent only a patch floor. +- Explicit pins retain their exact prerelease/build spelling, but comparisons use + SemVer precedence and ignore build metadata. +- An exact proc-macro pin still requires a macro-contract attestation; changing + the version line does not prove behavioral compatibility. +- Generated non-pinned target versions are clean three-component SemVer values. diff --git a/.github/skills/release-packages/scripts/apply-plan.ps1 b/.github/skills/release-packages/scripts/apply-plan.ps1 new file mode 100644 index 000000000..1180fd16b --- /dev/null +++ b/.github/skills/release-packages/scripts/apply-plan.ps1 @@ -0,0 +1,289 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Applies a resolved release plan atomically. + +.DESCRIPTION + Updates package and workspace dependency versions, generates changelogs and + READMEs, validates the workspace, and restores every touched file on failure. + +.PARAMETER RepoRoot + Workspace root containing Cargo.toml. + +.PARAMETER PlanPath + JSON emitted by resolve-plan.ps1. + +.PARAMETER SkipReadme + Skips `just readme`. Intended for synthetic workspaces that do not generate + crate READMEs. +#> +[CmdletBinding()] +param( + [string]$RepoRoot, + + [Parameter(Mandatory = $true)] + [string]$PlanPath, + + [switch]$SkipReadme +) + +$ErrorActionPreference = 'Stop' + +$skillRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = $skillRepoRoot +} else { + $RepoRoot = (Resolve-Path $RepoRoot).Path +} + +$changelogScript = Join-Path $PSScriptRoot 'release-changelog.ps1' +$rootManifest = Join-Path $RepoRoot 'Cargo.toml' +if (-not (Test-Path -LiteralPath $rootManifest)) { + throw "Repository root '$RepoRoot' does not contain Cargo.toml." +} + +$plan = Get-Content -LiteralPath (Resolve-Path $PlanPath) -Raw | ConvertFrom-Json +if ( + $plan.PSObject.Properties['status'] -and + $plan.status -ne 'resolved' +) { + throw "The release plan is '$($plan.status)' and cannot be applied." +} +$releases = @($plan.releases) +if ($releases.Count -eq 0) { + throw 'The release plan contains no packages.' +} + +function Set-Utf8Content { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + [System.IO.File]::WriteAllText( + $Path, + $Content, + [System.Text.UTF8Encoding]::new($false) + ) +} + +function Set-PackageManifestVersion { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedVersion, + [Parameter(Mandatory = $true)][string]$Version + ) + + $content = Get-Content -LiteralPath $Path -Raw + $packageSection = [regex]::Match( + $content, + '(?ms)^\[package\]\s*$.*?(?=^\[|\z)' + ) + if (-not $packageSection.Success) { + throw "Manifest '$Path' has no [package] section." + } + + $matches = [regex]::Matches( + $packageSection.Value, + '(?m)^(?\s*version\s*=\s*")(?[^"]+)(?".*)$' + ) + if ($matches.Count -ne 1) { + throw "Manifest '$Path' must contain exactly one literal [package] version." + } + if ($matches[0].Groups['value'].Value -ne $ExpectedVersion) { + throw "Manifest '$Path' is at '$($matches[0].Groups['value'].Value)', not planned version '$ExpectedVersion'." + } + + $updatedSection = $packageSection.Value.Remove( + $matches[0].Groups['value'].Index, + $matches[0].Groups['value'].Length + ).Insert($matches[0].Groups['value'].Index, $Version) + $updated = $content.Remove( + $packageSection.Index, + $packageSection.Length + ).Insert($packageSection.Index, $updatedSection) + Set-Utf8Content -Path $Path -Content $updated +} + +function Set-WorkspaceDependencyVersion { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$ExpectedVersion, + [Parameter(Mandatory = $true)][string]$Version + ) + + $content = Get-Content -LiteralPath $Path -Raw + $section = [regex]::Match( + $content, + '(?ms)^\[workspace\.dependencies\]\s*$.*?(?=^\[|\z)' + ) + if (-not $section.Success) { + throw "Manifest '$Path' has no [workspace.dependencies] section." + } + + $key = [regex]::Escape($Name) + $entryPattern = "(?m)^(?\s*(?:$key|`"$key`")\s*=\s*\{[^\r\n]*?\bversion\s*=\s*`")(?[^`"]+)(?`"[^\r\n]*\}\s*)$" + $matches = [regex]::Matches($section.Value, $entryPattern) + if ($matches.Count -ne 1) { + throw "Workspace dependency '$Name' must be one inline table with a version value." + } + if ($matches[0].Groups['value'].Value -ne $ExpectedVersion) { + throw "Workspace dependency '$Name' is at '$($matches[0].Groups['value'].Value)', not planned version '$ExpectedVersion'." + } + + $updatedSection = $section.Value.Remove( + $matches[0].Groups['value'].Index, + $matches[0].Groups['value'].Length + ).Insert($matches[0].Groups['value'].Index, $Version) + $updated = $content.Remove( + $section.Index, + $section.Length + ).Insert($section.Index, $updatedSection) + Set-Utf8Content -Path $Path -Content $updated +} + +function Invoke-CheckedCommand { + param( + [Parameter(Mandatory = $true)][string]$Command, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + $oldNativeErrorPreference = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + try { + $output = & $Command @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $PSNativeCommandUseErrorActionPreference = $oldNativeErrorPreference + } + if ($exitCode -ne 0) { + $detail = ($output | Out-String).Trim() + throw "Command failed: $Command $($Arguments -join ' ')`n$detail" + } +} + +$snapshot = @{} +function Save-OriginalFile { + param([Parameter(Mandatory = $true)][string]$Path) + + if ($snapshot.ContainsKey($Path)) { return } + $snapshot[$Path] = if (Test-Path -LiteralPath $Path) { + [pscustomobject]@{ + Exists = $true + Bytes = [System.IO.File]::ReadAllBytes($Path) + } + } else { + [pscustomobject]@{ + Exists = $false + Bytes = $null + } + } +} + +Save-OriginalFile -Path $rootManifest +Save-OriginalFile -Path (Join-Path $RepoRoot 'Cargo.lock') +foreach ($release in $releases) { + Save-OriginalFile -Path (Join-Path $RepoRoot "crates\$($release.folder)\Cargo.toml") + Save-OriginalFile -Path (Join-Path $RepoRoot "crates\$($release.folder)\CHANGELOG.md") +} +if (-not $SkipReadme) { + Get-ChildItem -Path (Join-Path $RepoRoot 'crates') -Directory | + ForEach-Object { Save-OriginalFile -Path (Join-Path $_.FullName 'README.md') } +} + +try { + foreach ($release in $releases) { + $packageManifest = Join-Path $RepoRoot "crates\$($release.folder)\Cargo.toml" + if (-not (Test-Path -LiteralPath $packageManifest)) { + throw "Package '$($release.folder)' was not found under '$RepoRoot\crates'." + } + + Set-PackageManifestVersion ` + -Path $packageManifest ` + -ExpectedVersion $release.from ` + -Version $release.to + Set-WorkspaceDependencyVersion ` + -Path $rootManifest ` + -Name $release.name ` + -ExpectedVersion $release.from ` + -Version $release.to + + $reasonsJson = @($release.cascadeReasons) | ConvertTo-Json -Depth 5 -Compress + & $changelogScript ` + -RepoRoot $RepoRoot ` + -PackageFolder $release.folder ` + -PackageName $release.name ` + -NewVersion $release.to ` + -PrBaseUrl 'https://github.com/microsoft/oxidizer' ` + -CascadeReasonsJson $reasonsJson + if (-not $?) { + throw "Changelog generation failed for '$($release.folder)'." + } + } + + if (-not $SkipReadme) { + Push-Location $RepoRoot + try { + Invoke-CheckedCommand -Command just -Arguments @('readme') + } finally { + Pop-Location + } + } + Invoke-CheckedCommand -Command cargo -Arguments @( + 'metadata', '--manifest-path', $rootManifest, '--format-version', '1' + ) + Invoke-CheckedCommand -Command cargo -Arguments @( + 'check', '--manifest-path', $rootManifest, '--workspace', '--all-features' + ) + + $oldNativeErrorPreference = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + try { + $metadataOutput = & cargo metadata ` + --manifest-path $rootManifest ` + --format-version 1 ` + --no-deps + $metadataExitCode = $LASTEXITCODE + } finally { + $PSNativeCommandUseErrorActionPreference = $oldNativeErrorPreference + } + if ($metadataExitCode -ne 0) { + throw 'Failed to verify package versions with cargo metadata.' + } + $metadata = $metadataOutput | ConvertFrom-Json + foreach ($release in $releases) { + $package = @($metadata.packages | Where-Object name -eq $release.name) + if ($package.Count -ne 1 -or $package[0].version -ne $release.to) { + throw "Applied version for '$($release.name)' does not match '$($release.to)'." + } + + $rootContent = Get-Content -LiteralPath $rootManifest -Raw + $key = [regex]::Escape($release.name) + $requirement = [regex]::Match( + $rootContent, + "(?m)^\s*(?:$key|`"$key`")\s*=\s*\{[^\r\n]*?\bversion\s*=\s*`"(?[^`"]+)`"" + ) + if (-not $requirement.Success -or $requirement.Groups['value'].Value -ne $release.to) { + throw "Workspace dependency version for '$($release.name)' does not match '$($release.to)'." + } + } +} catch { + foreach ($path in $snapshot.Keys) { + $original = $snapshot[$path] + if ($original.Exists) { + [System.IO.File]::WriteAllBytes($path, $original.Bytes) + } elseif (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Force + } + } + throw +} + +[ordered]@{ + applied = @($releases | ForEach-Object { $_.folder }) +} | ConvertTo-Json -Depth 3 diff --git a/.github/skills/release-packages/scripts/release-changelog.ps1 b/.github/skills/release-packages/scripts/release-changelog.ps1 new file mode 100644 index 000000000..70f11aff5 --- /dev/null +++ b/.github/skills/release-packages/scripts/release-changelog.ps1 @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Regenerates one package's CHANGELOG.md for a release, deterministically. + +.DESCRIPTION + A small, deterministic helper for the AI release skill + (.github/skills/release-packages/SKILL.md). Changelog generation is the + kind of mechanical, format-heavy sub-task that an agent should NOT reproduce + by hand -- grouping conventional commits into sections, rendering PR links, + folding `## Unreleased`, and emitting cascade "Now requires X of Y" bullets in + a stable order. Forwarding it to a script keeps the output byte-identical + regardless of which reasoning model drove the plan. + + This is a thin shell over the existing, tested Write-Changelog function in + scripts/lib/changelog.ps1; it does not reimplement any changelog logic. + It writes exactly one file: crates//CHANGELOG.md. + +.PARAMETER RepoRoot + Workspace root (directory containing the top-level Cargo.toml). Defaults to + the repository this script lives in. + +.PARAMETER PackageFolder + Folder name under crates/ for the package being released. + +.PARAMETER PackageName + Cargo package name. When omitted, it is resolved from workspace metadata. + +.PARAMETER NewVersion + The already-decided target version (e.g. '1.3.0'). This helper performs NO + version arithmetic -- the skill computes the version and passes it in. + +.PARAMETER PrBaseUrl + Base URL used to render PR links (e.g. https://github.com/microsoft/oxidizer). + +.PARAMETER CascadeReasonsJson + Optional JSON array describing why this package is being re-released because a + dependency was released. Each element: + { "Target": "", "Version": "", "Breaking": false } + Produces a "🔧 Maintenance" (or "⚠️ Breaking" if any reason is breaking) + section with one "Now requires `` of ``" bullet per reason. + +.EXAMPLE + ./.github/skills/release-packages/scripts/release-changelog.ps1 ` + -PackageFolder bytesbuf_io -NewVersion 0.9.0 ` + -PrBaseUrl https://github.com/microsoft/oxidizer ` + -CascadeReasonsJson '[{"Target":"bytesbuf","Version":"0.9.0","Breaking":true}]' +#> +[CmdletBinding()] +param( + [string]$RepoRoot, + + [Parameter(Mandatory = $true)] + [string]$PackageFolder, + + [string]$PackageName, + + [Parameter(Mandatory = $true)] + [string]$NewVersion, + + [string]$PrBaseUrl, + + [string]$CascadeReasonsJson +) + +$ErrorActionPreference = 'Stop' + +$skillRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path +$releasingLibrary = Join-Path $skillRepoRoot 'scripts\lib\releasing.ps1' +$changelogLibrary = Join-Path $skillRepoRoot 'scripts\lib\changelog.ps1' +foreach ($libraryPath in @($releasingLibrary, $changelogLibrary)) { + if (-not (Test-Path -LiteralPath $libraryPath)) { + throw "The release skill requires the shared library at '$libraryPath'." + } + . $libraryPath +} + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = $skillRepoRoot +} else { + $RepoRoot = (Resolve-Path $RepoRoot).Path +} + +Reset-ReleaseScriptCaches + +$packageFolderPath = Join-Path $RepoRoot "crates/$PackageFolder" +if (-not (Test-Path -LiteralPath (Join-Path $packageFolderPath 'Cargo.toml'))) { + throw "Package folder '$PackageFolder' was not found under 'crates/' in '$RepoRoot'." +} +if ([string]::IsNullOrWhiteSpace($PackageName)) { + $package = @(Get-WorkspacePackages -repoRoot $RepoRoot) | + Where-Object { $_.Folder -eq $PackageFolder } | + Select-Object -First 1 + if ($null -eq $package) { + throw "Package folder '$PackageFolder' was not found under 'crates/' in '$RepoRoot'." + } + $PackageName = $package.Name +} + +$changelogFile = Join-Path $packageFolderPath 'CHANGELOG.md' + +$cascadeReasons = $null +if (-not [string]::IsNullOrWhiteSpace($CascadeReasonsJson)) { + $cascadeReasons = @($CascadeReasonsJson | ConvertFrom-Json) +} + +# Write-Changelog resolves git history relative to the current directory, so run +# it from the workspace root. +Push-Location $RepoRoot +try { + Write-Changelog -packageName $PackageName ` + -newVersion $NewVersion ` + -packageFolder $packageFolderPath ` + -changelogFile $changelogFile ` + -prBaseUrl $PrBaseUrl ` + -cascadeReasons $cascadeReasons +} finally { + Pop-Location +} diff --git a/.github/skills/release-packages/scripts/release-facts.ps1 b/.github/skills/release-packages/scripts/release-facts.ps1 new file mode 100644 index 000000000..1e3bb5176 --- /dev/null +++ b/.github/skills/release-packages/scripts/release-facts.ps1 @@ -0,0 +1,995 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Emits the deterministic release "facts" for the workspace as JSON. + +.DESCRIPTION + This is a small, deterministic helper for the AI release skill + (.github/skills/release-packages/SKILL.md). It does NOT make any release + decisions and NEVER writes to the repository. It only gathers the objective + facts an agent needs to plan a release, so that different reasoning models + start from an identical, machine-checked fact base rather than re-deriving it + (and possibly diverging) by hand-parsing `cargo metadata` and `git` output. + + The facts are read from the existing, tested release library + (scripts/lib/releasing.ps1) so this script stays a thin shell: + + - Get-WorkspacePackages -> folder / name / version / published / + proc-macro-only / library-target / + dependency and exposure edges + (normal + build deps, dev excluded, + names normalised with '-' -> '_'). + - Get-PreviousVersionBumpCommit -> baseline commit sha for + cargo-semver-checks (--baseline-rev). + - Get-PackageUnreleasedChangeFiles -> exact paths changed under + crates//. + - Get-PackageLastReleaseBaseline -> the rev those paths were diffed + against, recorded per compile-fixture + obligation as baselineRev. + + Version-bump arithmetic, cascade resolution, change-type classification, and + all file writes are intentionally NOT done here -- those belong to the skill + (judgment + planning) and to cargo-semver-checks / release-changelog.ps1. + +.PARAMETER RepoRoot + Workspace root (directory containing the top-level Cargo.toml). Defaults to + the repository this script lives in. + +.PARAMETER BaseRef + Git ref used as the "previous release" boundary for baseline-commit lookup. + Defaults to HEAD (the last committed version bump == the previous release). + +.EXAMPLE + ./.github/skills/release-packages/scripts/release-facts.ps1 | + ConvertFrom-Json +#> +[CmdletBinding()] +param( + [string]$RepoRoot, + [string]$BaseRef = 'HEAD' +) + +$ErrorActionPreference = 'Stop' + +$skillRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path +$libraryPath = Join-Path $skillRepoRoot 'scripts\lib\releasing.ps1' +if (-not (Test-Path -LiteralPath $libraryPath)) { + throw "The release skill requires the shared library at '$libraryPath'." +} +. $libraryPath + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = $skillRepoRoot +} else { + $RepoRoot = (Resolve-Path $RepoRoot).Path +} + +# Start from a clean cache so repeated invocations (e.g. between mid-plan edits) +# never read stale cargo-metadata or git results. +Reset-ReleaseScriptCaches + +# Validate BaseRef once, up front, for a single clear failure. An unresolvable or +# un-fetched ref cannot yield meaningful facts. Get-PreviousVersionBumpCommit runs +# the same Test-GitRef internally and already throws on a bad ref, so this is not +# guarding against silent swallowing -- it just fails once here rather than once +# per package. +if (-not (Test-GitRef -Ref $BaseRef -RepoRoot $RepoRoot)) { + throw "Base ref '$BaseRef' could not be resolved in '$RepoRoot'. Ensure it is fetched (CI should checkout with fetch-depth: 0) and spelled correctly." +} + +$packages = @(Get-WorkspacePackages -repoRoot $RepoRoot) +$workspaceModifiedFiles = Get-PackageUnreleasedChangeFiles ` + -RepoRoot $RepoRoot ` + -IncludeUnpublished + +$packageByName = @{} +foreach ($package in $packages) { + $packageByName[$package.Name.Replace('-', '_')] = $package +} + +function Get-ReachableWorkspacePackages { + param([Parameter(Mandatory = $true)][pscustomobject]$Package) + + $seen = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + $queue = [System.Collections.Generic.Queue[string]]::new() + foreach ($dependency in @($Package.Deps)) { + $queue.Enqueue($dependency) + } + + while ($queue.Count -gt 0) { + $dependency = $queue.Dequeue() + if (-not $packageByName.ContainsKey($dependency)) { continue } + if (-not $seen.Add($dependency)) { continue } + foreach ($transitiveDependency in @($packageByName[$dependency].Deps)) { + $queue.Enqueue($transitiveDependency) + } + } + + return @($seen | Sort-Object) +} + +# A compile fixture is a consumer program whose *compile result* is the +# assertion: trybuild-style `tests/ui` and `tests/compile_fail` cases plus the +# sibling `.stderr`/`.stdout` files that record the expected failure. Their +# arrival, departure, or edit is the one mechanically visible trace a proc +# macro's compile contract leaves, and it frequently lands in the macro's +# runtime facade rather than in the macro crate itself -- which is exactly why +# it must be gathered across the whole macro review scope and not per package. +$script:CompileFixturePattern = + '^crates/[^/]+/tests/(?:ui|compile_fail)/.+\.(?:rs|stderr|stdout)$' + +function Get-CompileFixtureKind { + param([Parameter(Mandatory = $true)][string]$Path) + + if ($Path.EndsWith('.rs', [StringComparison]::Ordinal)) { return 'uiFixture' } + return 'uiExpectation' +} + +# Returns the sibling expectation paths for a fixture, or the fixture path for +# an expectation. Used to decide expectedResult and, in the resolver, to let one +# evidence entry discharge a fixture and its expectation files together. +function Get-CompileFixtureSiblings { + param([Parameter(Mandatory = $true)][string]$Path) + + if ($Path.EndsWith('.rs', [StringComparison]::Ordinal)) { + $stem = $Path.Substring(0, $Path.Length - 3) + return @("$stem.stderr", "$stem.stdout") + } + $stem = $Path.Substring(0, $Path.LastIndexOf('.')) + return @("$stem.rs") +} + +function Test-WorkingTreeFile { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$RelativePath + ) + + return Test-Path -LiteralPath ( + Join-Path $RepoRoot $RelativePath.Replace('/', '\') + ) +} + +# Compile-fixture changes owned by one package, derived from the same diff that +# produced modifiedFiles. Status comes from presence at the baseline rev versus +# presence in the working tree, so a fixture added in an uncommitted edit and a +# fixture added in a commit are reported identically. +function Get-PackageCompileFixtureChanges { + param( + [Parameter(Mandatory = $true)][string]$PackageFolder, + [Parameter(Mandatory = $true)][bool]$OwnerPublished, + [AllowNull()]$ModifiedFiles + ) + + $candidates = @( + @($ModifiedFiles) | + Where-Object { $null -ne $_ } | + ForEach-Object { $_.ToString().Replace('\', '/') } | + Where-Object { $_ -match $script:CompileFixturePattern } | + Sort-Object -Unique + ) + if ($candidates.Count -eq 0) { return @() } + + $baselineRev = Get-PackageLastReleaseBaseline ` + -RepoRoot $RepoRoot ` + -PackageFolder $PackageFolder + $baselineFiles = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + if (-not [string]::IsNullOrWhiteSpace($baselineRev)) { + $tracked = Invoke-Git -Arguments @( + 'ls-tree', + '-r', + '--name-only', + $baselineRev, + '--', + "crates/$PackageFolder/tests" + ) -RepoRoot $RepoRoot -AllowFailure + foreach ($line in @($tracked)) { + $entry = $line.ToString().Trim().Replace('\', '/') + if (-not [string]::IsNullOrWhiteSpace($entry)) { + [void]$baselineFiles.Add($entry) + } + } + } + + $items = New-Object 'System.Collections.Generic.List[object]' + foreach ($path in $candidates) { + $inBaseline = $baselineFiles.Contains($path) + $inCurrent = Test-WorkingTreeFile -RepoRoot $RepoRoot -RelativePath $path + $status = if (-not $inBaseline -and $inCurrent) { + 'added' + } elseif ($inBaseline -and -not $inCurrent) { + 'removed' + } elseif ($inBaseline -and $inCurrent) { + 'modified' + } else { + # Neither side has it: a transient path that came and went inside the + # unreleased window. It asserts nothing about either revision. + continue + } + + $kind = Get-CompileFixtureKind -Path $path + # A `.stderr`/`.stdout` file *is* a recorded compile failure; a `.rs` + # case is only known to be compile-fail when such a sibling exists on + # either side. Anything else stays null rather than guessing. + $expectedResult = if ($kind -eq 'uiExpectation') { + 'fail' + } else { + $hasExpectation = $false + foreach ($sibling in Get-CompileFixtureSiblings -Path $path) { + if ( + $baselineFiles.Contains($sibling) -or + (Test-WorkingTreeFile -RepoRoot $RepoRoot -RelativePath $sibling) + ) { + $hasExpectation = $true + break + } + } + if ($hasExpectation) { 'fail' } else { $null } + } + + $items.Add([ordered]@{ + ownerPackage = $PackageFolder + ownerPublished = $OwnerPublished + path = $path + kind = $kind + status = $status + expectedResult = $expectedResult + baselineRev = $baselineRev + }) | Out-Null + } + + # Hand back a materialised array: a List[object] is not safely wrapped by + # @() on PowerShell 7.4. Dictionaries do not unroll in the pipeline, so the + # caller's @(...) sees the items themselves. + return $items.ToArray() +} +# Classifies the crate's own diff to authored packaged `.rs` files (src, +# build.rs, or a custom library path -- never tests/benches/examples) into two +# independent signals: +# +# Implementation -- true when any added/removed line is real code: not a doc +# comment (`///`/`//!`), not a plain line comment (`//`), not blank. This +# gates whether a package may be classified breaking/nonbreaking on its own. +# Conservative: a missing baseline or a brand-new untracked source file +# counts as an implementation change, and block comments (`/* ... */`) read +# as implementation too. +# +# DocComment -- true when a rustdoc-visible doc comment (`///` or `//!`) +# was added or removed in a doc-eligible file (src or a custom lib path, but +# NOT build.rs, whose comments never reach rustdoc). This positively +# identifies a consumer-visible documentation change; the resolver requires +# `authored-doc-fix` only when it is set, so a plain `//` comment or a +# whitespace reflow -- which leave Implementation false too -- stay eligible +# for `internal-only`. +function Get-PackageSourceChangeKind { + param( + [Parameter(Mandatory = $true)][string]$PackageFolder, + [AllowNull()]$ModifiedFiles + ) + + $prefix = "crates/$PackageFolder/" + # Only packaged library source counts: files under `src/` (rustdoc-visible) + # and the crate's `build.rs` (compiled, but never rustdoc). Everything else a + # crate may carry -- `tests/`, `benches/`, `examples/`, helper `scripts/`, + # `xtask/` -- is excluded from the include allowlist and never ships, so a + # change there must not drive an own-diff classification or a doc release. + $candidates = @( + @($ModifiedFiles) | + Where-Object { $null -ne $_ } | + ForEach-Object { $_.ToString().Replace('\', '/') } | + Where-Object { + $_.EndsWith('.rs', [StringComparison]::OrdinalIgnoreCase) -and + $_.StartsWith($prefix, [StringComparison]::Ordinal) + } | + Where-Object { + $relative = $_.Substring($prefix.Length) + $relative.StartsWith('src/', [StringComparison]::Ordinal) -or + $relative -eq 'build.rs' + } | + Sort-Object -Unique + ) + if ($candidates.Count -eq 0) { + return [pscustomobject]@{ Implementation = $false; DocComment = $false } + } + + $baselineRev = Get-PackageLastReleaseBaseline ` + -RepoRoot $RepoRoot ` + -PackageFolder $PackageFolder + if ([string]::IsNullOrWhiteSpace($baselineRev)) { + return [pscustomobject]@{ Implementation = $true; DocComment = $false } + } + + $implementation = $false + $docComment = $false + foreach ($path in $candidates) { + # build.rs is packaged source but its comments never reach rustdoc, so it + # can raise Implementation but never DocComment. + $docEligible = $path.Substring($prefix.Length) -ne 'build.rs' + $diff = @( + Invoke-Git -Arguments @( + 'diff', + '--no-ext-diff', + '--no-color', + '--unified=0', + $baselineRev, + '--', + $path + ) -RepoRoot $RepoRoot -AllowFailure + ) + # A file listed as modified whose baseline-to-worktree diff is empty is an + # untracked new source file (git diff cannot see it): an implementation + # change by construction. + if ($diff.Count -eq 0) { + $implementation = $true + continue + } + + foreach ($line in $diff) { + $text = $line.ToString() + if ($text.Length -eq 0) { continue } + $marker = $text[0] + if ($marker -ne '+' -and $marker -ne '-') { continue } + if ($text.StartsWith('+++') -or $text.StartsWith('---')) { continue } + $content = $text.Substring(1).Trim() + if ($content.Length -eq 0) { continue } + if ( + ( + $content.StartsWith('///', [StringComparison]::Ordinal) -and + -not $content.StartsWith('////', [StringComparison]::Ordinal) + ) -or + $content.StartsWith('//!', [StringComparison]::Ordinal) + ) { + if ($docEligible) { $docComment = $true } + continue + } + if ($content.StartsWith('//', [StringComparison]::Ordinal)) { continue } + $implementation = $true + } + } + + return [pscustomobject]@{ Implementation = $implementation; DocComment = $docComment } +} + +function Get-ManifestChanges { + param( + [Parameter(Mandatory = $true)][string]$BaselineSha, + [Parameter(Mandatory = $true)][string]$PackageFolder + ) + + $manifestPath = "crates/$PackageFolder/Cargo.toml" + $diff = @( + Invoke-Git -Arguments @( + 'diff', + '--no-ext-diff', + '--no-color', + '--unified=999999', + $BaselineSha, + '--', + $manifestPath + ) -RepoRoot $RepoRoot + ) + if ($diff.Count -eq 0) { + return [pscustomobject]@{ + DependencyScopes = @() + OtherChanged = $false + } + } + + function New-OrdinalCountMap { + return [System.Collections.Generic.Dictionary[string, int]]::new( + [System.StringComparer]::Ordinal + ) + } + + $changes = @{ old = @{}; new = @{} } + foreach ($side in @('old', 'new')) { + foreach ($scope in @('normal', 'build', 'dev', 'features', 'metadata', 'other')) { + $changes[$side][$scope] = New-OrdinalCountMap + } + } + $oldSection = '' + $newSection = '' + + function Get-DependencyScope { + param([string]$Section) + + $normalizedSection = $Section.ToLowerInvariant() + if ( + $normalizedSection -match '^package\.metadata(?:\.|$)' -or + $normalizedSection -match '^lints(?:\.|$)' + ) { + return 'metadata' + } + $match = [regex]::Match( + $normalizedSection, + '^(?:target\..+\.)?(dependencies|build-dependencies|dev-dependencies)(?:\.|$)|^(features)$' + ) + if (-not $match.Success) { return $null } + $kind = if ($match.Groups[1].Success) { + $match.Groups[1].Value + } else { + $match.Groups[2].Value + } + $scope = switch ($kind) { + 'dependencies' { 'normal' } + 'build-dependencies' { 'build' } + 'dev-dependencies' { 'dev' } + 'features' { 'features' } + } + return $scope + } + + function Add-ScopedChange { + param( + [Parameter(Mandatory = $true)][string]$Side, + [AllowEmptyString()][string]$Section, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content + ) + + $inSingle = $false + $inDouble = $false + $escaped = $false + $commentIndex = -1 + for ($i = 0; $i -lt $Content.Length; $i++) { + $character = $Content[$i] + if ($inDouble -and $escaped) { + $escaped = $false + continue + } + if ($inDouble -and $character -eq '\') { + $escaped = $true + continue + } + if (-not $inDouble -and $character -eq "'") { + $inSingle = -not $inSingle + continue + } + if (-not $inSingle -and $character -eq '"') { + $inDouble = -not $inDouble + continue + } + if (-not $inSingle -and -not $inDouble -and $character -eq '#') { + $commentIndex = $i + break + } + } + $normalized = if ($commentIndex -ge 0) { + $Content.Substring(0, $commentIndex).Trim() + } else { + $Content.Trim() + } + if ( + [string]::IsNullOrWhiteSpace($normalized) + ) { + return + } + $scope = Get-DependencyScope -Section $Section + if ([string]::IsNullOrWhiteSpace($scope)) { $scope = 'other' } + $key = "$Section`0$normalized" + $map = $changes[$Side][$scope] + $count = if ($map.ContainsKey($key)) { $map[$key] } else { 0 } + $map[$key] = $count + 1 + } + + foreach ($record in $diff) { + if ($record -isnot [string]) { continue } + $line = [string]$record + if ( + $line.StartsWith('diff --git ', [StringComparison]::Ordinal) -or + $line.StartsWith('index ', [StringComparison]::Ordinal) -or + $line.StartsWith('--- ', [StringComparison]::Ordinal) -or + $line.StartsWith('+++ ', [StringComparison]::Ordinal) -or + $line.StartsWith('@@ ', [StringComparison]::Ordinal) + ) { + continue + } + if ($line.Length -eq 0 -or $line[0] -notin @(' ', '+', '-')) { + continue + } + + $content = $line.Substring(1) + $sectionMatch = [regex]::Match( + $content, + '^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$' + ) + $section = if ($sectionMatch.Groups[1].Success) { + $sectionMatch.Groups[1].Value.Trim() + } elseif ($sectionMatch.Groups[2].Success) { + $sectionMatch.Groups[2].Value.Trim() + } else { + $null + } + switch ($line[0]) { + ' ' { + if ($null -ne $section) { + $oldSection = $section + $newSection = $oldSection + } else { + if ($oldSection -cne $newSection) { + Add-ScopedChange -Side old -Section $oldSection -Content $content + Add-ScopedChange -Side new -Section $newSection -Content $content + } + } + } + '-' { + if ($null -ne $section) { + $oldSection = $section + } else { + Add-ScopedChange ` + -Side old ` + -Section $oldSection ` + -Content $content + } + } + '+' { + if ($null -ne $section) { + $newSection = $section + } else { + Add-ScopedChange ` + -Side new ` + -Section $newSection ` + -Content $content + } + } + } + } + + $changedScopes = @( + foreach ($scope in @('normal', 'build', 'dev', 'features', 'metadata', 'other')) { + $keys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($key in $changes.old[$scope].Keys) { [void]$keys.Add($key) } + foreach ($key in $changes.new[$scope].Keys) { [void]$keys.Add($key) } + $changed = $false + foreach ($key in @($keys | Sort-Object)) { + $oldCount = if ($changes.old[$scope].ContainsKey($key)) { + $changes.old[$scope][$key] + } else { + 0 + } + $newCount = if ($changes.new[$scope].ContainsKey($key)) { + $changes.new[$scope][$key] + } else { + 0 + } + if ( + $oldCount -ne $newCount + ) { + $changed = $true + break + } + } + if ($changed) { $scope } + } + ) + return [pscustomobject]@{ + DependencyScopes = @( + $changedScopes | + Where-Object { $_ -notin @('metadata', 'other') } + ) + OtherChanged = $changedScopes -contains 'other' + } +} + +# An external dependency's requirement is part of the published manifest, so a +# consumer resolves against it directly. When the crate also names that +# dependency's types in its own public API, moving the requirement to another +# compatibility line hands consumers a different type identity under the same +# paths -- a break no cargo-semver-checks run on this workspace can see, because +# nothing in THIS crate's rustdoc changed. +# +# The current side comes from cargo metadata, which has already resolved +# [workspace.dependencies] inheritance. The baseline side has to be read out of +# Git text: cargo cannot be pointed at a historical revision without +# materialising a whole workspace checkout, and every package has its own +# baseline commit. +$script:BaselineWorkspaceRequirementsCache = @{} + +function Get-BaselineWorkspaceRequirements { + param([Parameter(Mandatory = $true)][string]$BaselineSha) + + if ($script:BaselineWorkspaceRequirementsCache.ContainsKey($BaselineSha)) { + return $script:BaselineWorkspaceRequirementsCache[$BaselineSha] + } + + $text = @( + Invoke-Git -Arguments @('show', "${BaselineSha}:Cargo.toml") ` + -RepoRoot $RepoRoot -AllowFailure + ) -join "`n" + $requirements = Get-CargoWorkspaceRequirements -ManifestText $text + $script:BaselineWorkspaceRequirementsCache[$BaselineSha] = $requirements + return $requirements +} + +function Get-ExternalDependencyChanges { + param( + [AllowNull()][string]$BaselineSha, + [Parameter(Mandatory = $true)][string]$PackageFolder, + [Parameter(Mandatory = $true)]$CurrentExternalDeps, + [Parameter(Mandatory = $true)][System.Collections.Generic.HashSet[string]]$WorkspaceMemberNames + ) + + if ([string]::IsNullOrWhiteSpace($BaselineSha)) { return @() } + + $manifestText = @( + Invoke-Git -Arguments @( + 'show', + "${BaselineSha}:crates/$PackageFolder/Cargo.toml" + ) -RepoRoot $RepoRoot -AllowFailure + ) -join "`n" + # No manifest at the baseline means the crate did not exist then; there is + # no released requirement any change could invalidate. + if ([string]::IsNullOrWhiteSpace($manifestText)) { return @() } + + $baselineDeps = Get-CargoManifestDependencies ` + -ManifestText $manifestText ` + -WorkspaceRequirements (Get-BaselineWorkspaceRequirements -BaselineSha $BaselineSha) + + $names = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($name in $baselineDeps.Keys) { [void]$names.Add($name) } + foreach ($name in $CurrentExternalDeps.Keys) { [void]$names.Add($name) } + + $items = New-Object 'System.Collections.Generic.List[object]' + foreach ($name in @($names | Sort-Object { $_ } -CaseSensitive)) { + # A workspace member is released by this same plan, so its version + # requirement is the cascade's business, not this lane's. Membership is + # judged on the current workspace on both sides: a crate that moved + # between the registry and the workspace changes identity for reasons + # this comparison cannot express. + if ($WorkspaceMemberNames.Contains($name)) { continue } + + $baselineRaw = $null + if ($baselineDeps.Contains($name)) { $baselineRaw = $baselineDeps[$name].Requirement } + $currentRaw = $null + if ($CurrentExternalDeps.Contains($name)) { $currentRaw = $CurrentExternalDeps[$name].Requirement } + + $baselineReq = Get-NormalizedCargoRequirement -Requirement $baselineRaw + $currentReq = Get-NormalizedCargoRequirement -Requirement $currentRaw + if ($null -eq $baselineReq -and $null -eq $currentReq) { continue } + if ($baselineReq -ceq $currentReq) { continue } + + $kinds = if ($CurrentExternalDeps.Contains($name)) { + @($CurrentExternalDeps[$name].Kinds) + } elseif ($baselineDeps.Contains($name)) { + @($baselineDeps[$name].Kinds) + } else { + @() + } + + $items.Add([ordered]@{ + name = $name + baselineReq = $baselineReq + currentReq = $currentReq + kinds = @($kinds) + breaking = [bool](Test-CargoRequirementBreaking ` + -BaselineRequirement $baselineReq ` + -CurrentRequirement $currentReq) + baselineRev = $BaselineSha + }) | Out-Null + } + + return $items.ToArray() +} + +$workspaceMemberNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal +) +foreach ($package in $packages) { + [void]$workspaceMemberNames.Add($package.Name.Replace('-', '_')) +} + +$factPackages = foreach ($package in $packages) { + if ( + @($package.MacroRuntimePartners).Count -gt 0 -and + -not [bool]$package.IsProcMacroOnly + ) { + throw "Package '$($package.Folder)' declares macro_runtime but is not proc-macro-only." + } + foreach ($partner in @($package.MacroRuntimePartners)) { + if (-not $packageByName.ContainsKey($partner)) { + throw "Package '$($package.Folder)' declares unknown macro_runtime partner '$partner'." + } + if (-not [bool]$packageByName[$partner].Published) { + throw "Package '$($package.Folder)' declares unpublished macro_runtime partner '$partner'." + } + } + $uncheckedTarget = -not [bool]$package.IsProcMacroOnly -and -not [bool]$package.HasLibraryTarget + $reachablePackages = @(Get-ReachableWorkspacePackages -Package $package) + $exposedPackages = if ([bool]$package.IsProcMacroOnly) { + @() + } elseif ($uncheckedTarget) { + @($package.Deps) + } else { + @( + foreach ($targetName in $reachablePackages) { + $target = $packageByName[$targetName] + if ($null -eq $target) { continue } + if ([bool]$target.IsProcMacroOnly) { continue } + + $exposed = if (@($package.Deps) -contains $targetName) { + Test-PackageExposesTarget ` + -Dependent $package ` + -TargetPackageName $target.Name + } else { + Test-PackageAllowlistNamesTarget ` + -Dependent $package ` + -TargetPackageName $target.Name ` + -TargetCrateRoot $target.CrateRoot + } + if ($exposed) { $targetName } + } + ) + } + $macroPublicPackages = @( + foreach ($targetName in $reachablePackages) { + $target = $packageByName[$targetName] + if ($null -eq $target -or -not [bool]$target.IsProcMacroOnly) { + continue + } + + $isDirect = @($package.Deps) -contains $targetName + $published = if ($isDirect) { + Test-PackageAllowlistNamesDirectTarget ` + -Dependent $package ` + -TargetPackageName $target.Name + } else { + Test-PackageAllowlistNamesTarget ` + -Dependent $package ` + -TargetPackageName $target.Name ` + -TargetCrateRoot $target.CrateRoot ` + -WildcardIsEvidence $false + } + if ($published) { $targetName } + } + ) + + # baselineSha is the crate's previous version-bump commit, or null if none can + # be found. Note a crate's introducing commit counts as a bump, so in practice + # even a never-released crate gets a baselineSha (its own first commit) -- use + # the everReleased fact below, NOT hasBaseline, to tell a first-ever release + # apart from a real one. + $bump = Get-PreviousVersionBumpCommit -RepoRoot $RepoRoot -BaseRef $BaseRef -PackageFolder $package.Folder + if ($null -ne $bump) { $baselineSha = $bump.Sha } + $manifestChanges = if ($null -ne $baselineSha) { + Get-ManifestChanges ` + -BaselineSha $baselineSha ` + -PackageFolder $package.Folder + } else { + [pscustomobject]@{ + DependencyScopes = @() + OtherChanged = $false + } + } + + $externalDepChanges = @( + Get-ExternalDependencyChanges ` + -BaselineSha $baselineSha ` + -PackageFolder $package.Folder ` + -CurrentExternalDeps $package.ExternalDeps ` + -WorkspaceMemberNames $workspaceMemberNames + ) + # Proc macros export behavior, not foreign type identity: a macro's public + # surface is the syntax it accepts and the code it generates, and nothing a + # consumer writes can name `syn::Error` through it. Its own dependency bumps + # are therefore private by construction, and the compile-fixture lane is what + # governs its contract. + $externalExposedDeps = if ([bool]$package.IsProcMacroOnly) { + @() + } elseif ($uncheckedTarget) { + @($package.ExternalDeps.Keys) + } else { + @( + foreach ($externalName in $package.ExternalDeps.Keys) { + $exposed = Test-PackageExposesTarget ` + -Dependent $package ` + -TargetPackageName $externalName + if ($exposed) { $externalName } + } + ) + } + + # A requirement inherited from [workspace.dependencies] changes the crate's + # PUBLISHED manifest -- cargo publish inlines the resolved value -- while + # leaving every file under crates// untouched. Without this + # promotion such a crate looks unmodified and never enters review at all. + $externalScopes = @( + @( + foreach ($change in $externalDepChanges) { @($change.kinds) } + ) | Where-Object { $_ } | Sort-Object -Unique + ) + $hasExternalDepChange = $externalDepChanges.Count -gt 0 + $hasPackageModifiedFiles = $workspaceModifiedFiles.ContainsKey($package.Folder) + $packageModifiedFiles = if ($hasPackageModifiedFiles) { + @($workspaceModifiedFiles[$package.Folder]) + } else { + @() + } + + # Classify the crate's own authored source diff: whether real implementation + # changed (gates own breaking/nonbreaking classification) and whether a + # rustdoc-visible doc comment changed (gates authored-doc-fix selection). + $sourceChange = if ($hasPackageModifiedFiles) { + Get-PackageSourceChangeKind ` + -PackageFolder $package.Folder ` + -ModifiedFiles $packageModifiedFiles + } else { + [pscustomobject]@{ Implementation = $false; DocComment = $false } + } + + [ordered]@{ + folder = $package.Folder + name = $package.Name + version = $package.Version + published = [bool]$package.Published + procMacroOnly = [bool]$package.IsProcMacroOnly + hasLibraryTarget = [bool]$package.HasLibraryTarget + deps = @($package.Deps) + # Includes direct exposure edges and positively identified indirect + # re-export edges to transitively reachable workspace packages. + exposedDeps = @($exposedPackages | Sort-Object -Unique) + # Proc-macro entry points are behavioral contracts, not Rust type + # identities. Keep their public re-export edges separate so a macro's + # reviewed contract change, rather than its Cargo version, controls + # breaking propagation. + macroPublicDeps = @($macroPublicPackages | Sort-Object -Unique) + macroImplementationClosure = if ([bool]$package.IsProcMacroOnly) { + @($reachablePackages) + } else { + @() + } + macroRuntimePartners = @($package.MacroRuntimePartners) + exposureUnknown = $uncheckedTarget + baselineSha = $baselineSha + hasBaseline = ($null -ne $baselineSha) + # Whether the crate has ever been published, determined from its release + # tags. A crate's introducing commit counts as a version bump, so + # hasBaseline alone cannot distinguish a first-ever release from a real one; + # cargo-semver-checks against an unpublished baseline would classify normal + # pre-publication churn as breaking. The release skill's Step 3 branches on + # this fact. + everReleased = [bool](Invoke-Git -Arguments @('tag', '--list', "$($package.Name)-v*") -RepoRoot $RepoRoot) + modified = [bool]$package.Published -and + ($hasPackageModifiedFiles -or $hasExternalDepChange) + # Note: the if/else empty branch serializes as JSON null (a PowerShell + # ConvertTo-Json quirk), which is the established shape the resolver reads. + modifiedFiles = if ($hasPackageModifiedFiles) { @($packageModifiedFiles) } else { @() } + modifiedFileCount = $packageModifiedFiles.Count + manifestDependencyScopes = @( + # Keeps Get-ManifestChanges' scope order, then appends whatever the + # external-dependency lane adds, so the existing sequence is stable. + $( + $seenScopes = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($scope in @($manifestChanges.DependencyScopes)) { + if ($seenScopes.Add($scope)) { $scope } + } + foreach ($scope in $externalScopes) { + if ($seenScopes.Add($scope)) { $scope } + } + ) + ) + manifestOtherChanged = [bool]$manifestChanges.OtherChanged + # True only when packaged Rust source changed beyond comments; gates an + # own-diff breaking/nonbreaking classification (see resolve-plan.ps1). + rustImplementationChanged = [bool]$sourceChange.Implementation + # True only when a rustdoc-visible doc comment (`///`/`//!`) changed in a + # doc-eligible source file; gates the authored-doc-fix selection rule. + docCommentChanged = [bool]$sourceChange.DocComment + workspaceModified = $hasPackageModifiedFiles -or $hasExternalDepChange + # Effective non-dev external dependency requirement changes between this + # crate's release baseline and the working tree, inheritance resolved. + externalDepChanges = @($externalDepChanges) + # The subset of current external dependencies whose types this crate's + # public API may name. Fail-closed: absent or unreadable exposure + # metadata counts as exposed. + externalExposedDeps = @($externalExposedDeps | Sort-Object -Unique) + # Filled in by the macro review-scope pass below, once macroRuntimePartners + # is complete. Always present so the resolver can validate the schema + # uniformly; only proc-macro packages ever carry entries. + macroCompileFixtureChanges = @() + } +} + +$factByName = @{} +foreach ($fact in $factPackages) { + $factByName[$fact.name.Replace('-', '_')] = $fact +} +foreach ($dependent in $factPackages) { + if (-not [bool]$dependent.published) { continue } + foreach ($macroName in @($dependent.macroPublicDeps)) { + $macroFact = $factByName[$macroName] + if ($null -eq $macroFact) { continue } + $macroFact['macroRuntimePartners'] = @( + @($macroFact.macroRuntimePartners) + + $dependent.name.Replace('-', '_') | + Sort-Object -Unique + ) + } +} + +# Compile-fixture obligations are gathered per proc macro across the SAME review +# scope the resolver already enforces (the macro itself, its modified +# implementation closure, and its modified runtime partners). Running after the +# runtime-partner back-fill is what lets a fixture added in the facade crate -- +# where it is otherwise indistinguishable from an ordinary test-only edit -- +# reach the macro whose compile contract it actually documents. +# +# scopeRole records WHY a fixture is in scope, because the two roles answer +# different questions. A fixture owned by the macro or by a facade that +# re-exports it is a consumer program for that macro, so its outcome speaks for +# the macro's compile contract. A fixture owned by a published implementation +# dependency is a consumer program for THAT crate, which carries its own release +# classification; it is still reported so the review cannot miss it, but the +# resolver does not let it set the macro's verdict floor. +$fixtureChangesByFolder = @{} +foreach ($fact in $factPackages) { + $fixtureChangesByFolder[$fact.folder] = @( + Get-PackageCompileFixtureChanges ` + -PackageFolder $fact.folder ` + -OwnerPublished ([bool]$fact.published) ` + -ModifiedFiles $fact.modifiedFiles + ) +} +foreach ($fact in $factPackages) { + if (-not [bool]$fact.procMacroOnly) { continue } + + $partnerNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($partner in @($fact.macroRuntimePartners)) { + [void]$partnerNames.Add($partner) + } + $closureNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($member in @($fact.macroImplementationClosure)) { + [void]$closureNames.Add($member) + } + + $scopeRoleByFolder = [ordered]@{} + $scopeRoleByFolder[$fact.folder] = 'self' + foreach ($candidate in $factPackages) { + if ($candidate.folder -eq $fact.folder) { continue } + if (-not [bool]$candidate.workspaceModified) { continue } + $normalizedName = $candidate.name.Replace('-', '_') + if ($partnerNames.Contains($normalizedName)) { + $scopeRoleByFolder[$candidate.folder] = 'runtimePartner' + } elseif ($closureNames.Contains($normalizedName)) { + $scopeRoleByFolder[$candidate.folder] = 'implementationClosure' + } + } + + $byKey = @{} + foreach ($folder in $scopeRoleByFolder.Keys) { + foreach ($item in @($fixtureChangesByFolder[$folder])) { + $scoped = [ordered]@{} + foreach ($property in $item.Keys) { $scoped[$property] = $item[$property] } + $scoped['scopeRole'] = $scopeRoleByFolder[$folder] + $byKey["$($item.ownerPackage)`u{0000}$($item.path)"] = $scoped + } + } + $orderedKeys = [string[]]@($byKey.Keys) + [Array]::Sort($orderedKeys, [StringComparer]::Ordinal) + $fact['macroCompileFixtureChanges'] = @( + foreach ($key in $orderedKeys) { $byKey[$key] } + ) +} + +[ordered]@{ + schemaVersion = 5 + repoRoot = $RepoRoot + baseRef = $BaseRef + packages = @($factPackages) +} | ConvertTo-Json -Depth 8 diff --git a/.github/skills/release-packages/scripts/resolve-plan.ps1 b/.github/skills/release-packages/scripts/resolve-plan.ps1 new file mode 100644 index 000000000..0a1d39f8b --- /dev/null +++ b/.github/skills/release-packages/scripts/resolve-plan.ps1 @@ -0,0 +1,1885 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Resolves a deterministic release plan from facts and model classifications. + +.DESCRIPTION + Performs only mechanical work: token parsing, version arithmetic, dependency + closure, type-exposure and macro-contract propagation, pin reconciliation, + and topological ordering. The release skill remains responsible for + classifying source diffs and reviewing proc-macro behavior. + +.PARAMETER FactsPath + JSON emitted by release-facts.ps1. + +.PARAMETER RequestPath + JSON with mode, tokens, selectionDecisions, classifications, + macroContracts, and optional force: + { + "mode": "targeted", + "tokens": ["bytesbuf@breaking"], + "selectionDecisions": {}, + "classifications": { + "bytesbuf": "patch", + "bytesbuf_io": { "changeType": "patch", "manualReview": false } + }, + "macroContracts": { + "templated_uri_macros": { + "verdict": "compatible", + "reviewedPackages": [ + "templated_uri_macros", + "templated_uri_macros_impl" + ], + "channels": { + "exportedMacros": "unchanged", + "acceptedSyntax": "unchanged", + "compileBehavior": "unchanged", + "generatedApi": "unchanged", + "generatedRuntimePaths": "unchanged", + "hygiene": "unchanged" + }, + "evidence": ["Expansion snapshots and compile fixtures are unchanged."], + "compileEvidence": [ + { + "ownerPackage": "templated_uri", + "path": "crates/templated_uri/tests/ui/bad_template.rs", + "baseline": { "revision": "", "result": "fail", "exitCode": 101 }, + "current": { "revision": "worktree", "result": "fail", "exitCode": 101 } + } + ] + } + }, + "force": false + } + + compileEvidence is required for every fixture the facts report changed in the + macro's review scope (macroCompileFixtureChanges). Measured outcomes derive a + verdict floor -- pass to fail is breaking, fail to pass is nonbreaking, an + unchanged outcome is compatible -- and a declared verdict below that floor + blocks the plan. + + regressionEvidence is required for every selection decision whose reason is + behavior-fix (changed/all mode): + "selectionDecisions": { + "cachet_tier": { + "decision": "accept", + "reason": "behavior-fix", + "evidence": ["Eviction now honors the configured tier bound."], + "regressionEvidence": [ + { + "kind": "consumer-runtime", + "probe": "cargo test -p cachet_tier --test eviction", + "baseline": { "revision": "", "result": "fail", "exitCode": 101 }, + "current": { "revision": "worktree", "result": "pass", "exitCode": 0 } + } + ] + } + } + + Each entry pairs one consumer-runtime, consumer-compile, or packaged-artifact + probe measured at the release baseline and at the current revision; only a + baseline failure that now passes demonstrates the fix. Any other outcome, or + a measurement that cannot be read, blocks the plan. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$FactsPath, + + [Parameter(Mandatory = $true)] + [string]$RequestPath +) + +$ErrorActionPreference = 'Stop' + +$skillRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path +$libraryPath = Join-Path $skillRepoRoot 'scripts\lib\releasing.ps1' +if (-not (Test-Path -LiteralPath $libraryPath)) { + throw "The release skill requires the shared library at '$libraryPath'." +} +. $libraryPath + +function ConvertTo-InternalChangeType { + param( + [AllowNull()][string]$Value, + [switch]$AllowNone + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + if ($AllowNone) { return 'none' } + throw 'A change type is required.' + } + + switch ($Value.ToLowerInvariant()) { + 'breaking' { return 'breaking' } + 'nonbreaking' { return 'non-breaking' } + 'non-breaking' { return 'non-breaking' } + 'patch' { return 'patch' } + 'none' { + if ($AllowNone) { return 'none' } + throw "Change type 'none' is not valid here." + } + default { throw "Unknown change type '$Value'." } + } +} + +function Get-StrongerChangeType { + param( + [Parameter(Mandatory = $true)][string]$Left, + [Parameter(Mandatory = $true)][string]$Right + ) + + if ($script:ChangeTypeRank[$Left] -ge $script:ChangeTypeRank[$Right]) { + return $Left + } + return $Right +} + +function Get-RequestValue { + param( + [AllowNull()]$Container, + [Parameter(Mandatory = $true)]$Fact + ) + + if ($null -eq $Container) { return $null } + $property = $Container.PSObject.Properties[$Fact.folder] + if ($null -eq $property) { + $property = $Container.PSObject.Properties[$Fact.name] + } + if ($null -eq $property) { return $null } + return $property.Value +} + +# The measured half of a before/after probe: which revision was exercised, what +# it did, and the exit status that proves it. Compile fixtures and behaviour +# probes share this parser so they cannot drift apart on what counts as a usable +# measurement. +function Get-MeasuredOutcome { + param([AllowNull()]$Value) + + if ($null -eq $Value -or $Value -is [string]) { + return [pscustomobject]@{ + Result = $null; Revision = $null; ExitCode = $null; Complete = $false + } + } + + $result = ($Value.result ?? '').ToString().Trim().ToLowerInvariant() + $revision = ($Value.revision ?? '').ToString().Trim() + $exitCodeValue = $Value.exitCode + $exitCode = $null + if ($null -ne $exitCodeValue -and $exitCodeValue -is [ValueType]) { + $exitCode = [int]$exitCodeValue + } elseif ( + $exitCodeValue -is [string] -and + [int]::TryParse($exitCodeValue, [ref]$null) + ) { + $exitCode = [int]$exitCodeValue + } + + $complete = ( + $result -in @('pass', 'fail') -and + -not [string]::IsNullOrWhiteSpace($revision) -and + $null -ne $exitCode + ) + return [pscustomobject]@{ + Result = if ($complete) { $result } else { $null } + Revision = $revision + ExitCode = $exitCode + Complete = $complete + } +} + +$script:RegressionEvidenceKinds = @( + 'consumer-runtime', + 'consumer-compile', + 'packaged-artifact' +) + +# A behaviour fix is a claim about observable behaviour, so it is only credible +# when the same probe is shown failing at the release baseline and passing now. +# Each entry pairs the two runs of one probe, which is what makes "the same +# probe" mechanically checkable rather than a narrative assertion. +function Get-RegressionEvidence { + param( + [Parameter(Mandatory = $true)][string]$Package, + [AllowNull()]$Value + ) + + $entries = New-Object 'System.Collections.Generic.List[object]' + $issues = New-Object 'System.Collections.Generic.List[string]' + $demonstrated = $false + + foreach ($item in @($Value)) { + if ($null -eq $item) { continue } + if ($item -is [string]) { + throw "Regression evidence in selection decision '$Package' must be an object with kind, probe, baseline, and current." + } + $probe = ($item.probe ?? '').ToString().Trim() + if ([string]::IsNullOrWhiteSpace($probe)) { + throw "Regression evidence in selection decision '$Package' must name the probe it exercised." + } + $kind = ($item.kind ?? '').ToString().Trim().ToLowerInvariant() + if ($kind -notin $script:RegressionEvidenceKinds) { + throw "Regression evidence '$probe' in selection decision '$Package' must use kind $($script:RegressionEvidenceKinds -join ', ')." + } + + $baseline = Get-MeasuredOutcome -Value $item.baseline + $current = Get-MeasuredOutcome -Value $item.current + foreach ($side in @( + [pscustomobject]@{ Name = 'baseline'; Outcome = $baseline }, + [pscustomobject]@{ Name = 'current'; Outcome = $current } + )) { + if (-not $side.Outcome.Complete) { + $issues.Add("Regression evidence '$probe' in selection decision '$Package' does not record a $($side.Name) pass/fail result with a revision and exit code.") | + Out-Null + continue + } + # An exit code that contradicts the recorded result means the + # measurement was mis-transcribed; neither half can be trusted. + if (($side.Outcome.Result -eq 'pass') -ne ($side.Outcome.ExitCode -eq 0)) { + $issues.Add("Regression evidence '$probe' in selection decision '$Package' records a $($side.Name) result of '$($side.Outcome.Result)' with exit code $($side.Outcome.ExitCode).") | + Out-Null + } + } + + $outcome = $null + if ( + $baseline.Complete -and $current.Complete -and + ($baseline.Result -eq 'pass') -eq ($baseline.ExitCode -eq 0) -and + ($current.Result -eq 'pass') -eq ($current.ExitCode -eq 0) + ) { + if ($baseline.Revision -eq $current.Revision) { + # One revision measured twice compares nothing. + $issues.Add("Regression evidence '$probe' in selection decision '$Package' measures revision '$($baseline.Revision)' on both sides.") | + Out-Null + } else { + $outcome = "$($baseline.Result)->$($current.Result)" + if ($baseline.Result -eq 'fail' -and $current.Result -eq 'pass') { + $demonstrated = $true + } + } + } + + $entries.Add([ordered]@{ + kind = $kind + probe = $probe + outcome = $outcome ?? 'inconclusive' + }) | Out-Null + } + + $ordered = $entries.ToArray() | + Sort-Object -Property @{ Expression = { "$($_.kind)`u{0000}$($_.probe)" } } + return [pscustomobject]@{ + Entries = @($ordered) + Issues = @($issues | Sort-Object -Unique) + Demonstrated = $demonstrated + } +} + +function ConvertTo-CleanStringList { + param([AllowNull()]$Values) + + return @( + $Values | + Where-Object { $null -ne $_ } | + ForEach-Object { $_.ToString().Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) +} + +function Get-NormalizedModifiedFiles { + param([Parameter(Mandatory = $true)]$Fact) + + return @( + $Fact.modifiedFiles | + Where-Object { $null -ne $_ } | + ForEach-Object { $_.ToString().Replace('\', '/') } + ) +} + +function Get-SelectionDecision { + param( + [Parameter(Mandatory = $true)]$Fact, + [Parameter(Mandatory = $true)]$Request, + [Parameter(Mandatory = $true)][string]$Mode + ) + + $value = $Request.selectionDecisions.PSObject.Properties[$Fact.folder].Value + if ($null -eq $value -or $value -is [string]) { + throw "Selection decision '$($Fact.folder)' must include decision, reason, and evidence." + } + + $decision = ($value.decision ?? '').ToString().ToLowerInvariant() + if ($decision -notin @('accept', 'decline')) { + throw "Selection decision '$($Fact.folder)' must be accept or decline." + } + + $reason = ($value.reason ?? '').ToString().ToLowerInvariant() + $acceptedReasons = @( + 'breaking', + 'nonbreaking-api', + 'behavior-fix', + 'authored-doc-fix', + 'runtime-manifest-change', + 'first-release', + 'explicit-release' + ) + $declinedReasons = @( + 'test-only', + 'benchmark-only', + 'dev-dependency-only', + 'release-metadata-only', + 'generated-artifact-only', + 'internal-only', + 'unchanged' + ) + $allowedReasons = if ($decision -eq 'accept') { + $acceptedReasons + } else { + $declinedReasons + } + if ($reason -notin $allowedReasons) { + throw "Selection decision '$($Fact.folder)' has invalid $decision reason '$reason'." + } + if ( + $reason -eq 'explicit-release' -and + ($Mode -ne 'all' -or [bool]$Fact.modified) + ) { + throw "Selection reason 'explicit-release' is only valid for an unchanged package in all mode." + } + $manifestDependencyScopes = @($Fact.manifestDependencyScopes) + $hasRuntimeDependencyChange = ( + $manifestDependencyScopes -contains 'normal' -or + $manifestDependencyScopes -contains 'build' -or + $manifestDependencyScopes -contains 'features' + ) + if ($reason -eq 'runtime-manifest-change' -and -not $hasRuntimeDependencyChange) { + throw "Selection reason 'runtime-manifest-change' for '$($Fact.folder)' requires a changed normal/build dependency or package feature declaration." + } + if ($decision -eq 'decline' -and $hasRuntimeDependencyChange) { + throw "Selection decision '$($Fact.folder)' cannot decline a changed normal/build dependency or package feature declaration." + } + # A published manifest dependency change is more consequential than a doc + # tweak, so it owns the reason: `authored-doc-fix` cannot be paired with a + # normal/build/features change. (A real API addition still elevates to + # nonbreaking-api, and an exposed breaking dependency to breaking.) + if ($reason -eq 'authored-doc-fix' -and $hasRuntimeDependencyChange) { + throw "Selection reason 'authored-doc-fix' for '$($Fact.folder)' cannot be used alongside a normal/build dependency or package feature change; use 'runtime-manifest-change'." + } + $packagePrefix = "crates/$($Fact.folder)/" + $otherFiles = @( + Get-NormalizedModifiedFiles -Fact $Fact | + Where-Object { + if (-not $_.StartsWith($packagePrefix, [StringComparison]::Ordinal)) { + return $true + } + $relative = $_.Substring($packagePrefix.Length) + return $relative -notin @('Cargo.toml', 'README.md', 'CHANGELOG.md') + } + ) + if ( + $decision -eq 'accept' -and + $manifestDependencyScopes.Count -eq 1 -and + $manifestDependencyScopes[0] -eq 'dev' -and + -not [bool]$Fact.manifestOtherChanged -and + $otherFiles.Count -eq 0 + ) { + throw "Selection decision '$($Fact.folder)' cannot accept a dev-dependency-only manifest change." + } + if ( + $decision -eq 'decline' -and + $manifestDependencyScopes.Count -eq 1 -and + $manifestDependencyScopes[0] -eq 'dev' -and + -not [bool]$Fact.manifestOtherChanged -and + $otherFiles.Count -eq 0 -and + $reason -ne 'dev-dependency-only' + ) { + throw "Selection decision '$($Fact.folder)' must classify a pure dev dependency manifest edit as 'dev-dependency-only'." + } + if ($reason -eq 'dev-dependency-only') { + if ( + $manifestDependencyScopes -notcontains 'dev' -or + $hasRuntimeDependencyChange -or + [bool]$Fact.manifestOtherChanged + ) { + throw "Selection reason 'dev-dependency-only' for '$($Fact.folder)' requires only changed dev dependency declarations and ignorable release metadata." + } + if ($otherFiles.Count -gt 0) { + throw "Selection reason 'dev-dependency-only' for '$($Fact.folder)' cannot ignore changed source, tests, benchmarks, or authored documentation." + } + } + # A generated artifact is exactly this crate's own README or CHANGELOG. + # "Generated-only" means at least one file changed and every changed file is + # one of those: it forces `generated-artifact-only` and forbids it once any + # Cargo.toml (metadata) or other path is present, so the two decline reasons + # are mutually exclusive and each diff shape has one canonical reason. + $changedFiles = @(Get-NormalizedModifiedFiles -Fact $Fact) + $isGeneratedOnly = $changedFiles.Count -gt 0 -and @( + $changedFiles | Where-Object { + -not ( + $_.StartsWith($packagePrefix, [StringComparison]::Ordinal) -and + $_.Substring($packagePrefix.Length) -in @('README.md', 'CHANGELOG.md') + ) + } + ).Count -eq 0 + if ($reason -eq 'generated-artifact-only' -and -not $isGeneratedOnly) { + throw "Selection reason 'generated-artifact-only' for '$($Fact.folder)' requires that only this crate's generated README.md or CHANGELOG.md changed; a Cargo.toml or other edit is 'release-metadata-only'." + } + if ($reason -eq 'release-metadata-only' -and $isGeneratedOnly) { + throw "Selection reason 'release-metadata-only' for '$($Fact.folder)' cannot classify a change to only a generated README.md or CHANGELOG.md; use 'generated-artifact-only'." + } + # A rustdoc-visible doc comment changed (facts field docCommentChanged) while + # rustImplementationChanged is false: the crate's own diff is documentation + # only, which ships in rustdoc and is consumer-visible. With no + # runtime-manifest change and no exposed breaking external dependency to + # elevate it, the one canonical outcome is accept `authored-doc-fix` -- it is + # not an `internal-only` refactor (the docs did change) nor any other + # decline. A plain `//` comment or whitespace edit leaves docCommentChanged + # false and stays eligible for `internal-only`. Proc macros are governed by + # their contract, not this rule. + if ( + [bool]$Fact.everReleased -and + -not [bool]$Fact.procMacroOnly -and + [bool]$Fact.docCommentChanged -and + -not [bool]$Fact.rustImplementationChanged -and + -not $hasRuntimeDependencyChange -and + @( + @($Fact.externalDepChanges) | + Where-Object { + [bool]$_.breaking -and @($Fact.externalExposedDeps) -contains $_.name + } + ).Count -eq 0 -and + -not ($decision -eq 'accept' -and $reason -eq 'authored-doc-fix') + ) { + throw "Selection decision '$($Fact.folder)' changes a rustdoc-visible doc comment with no implementation change; a consumer-visible doc change must be accepted as 'authored-doc-fix', not '$reason'." + } + if ($decision -eq 'accept' -and -not [bool]$Fact.everReleased) { + if ($reason -ne 'first-release') { + throw "Never-released package '$($Fact.folder)' must use selection reason 'first-release'." + } + $releaseWorthyFiles = @( + Get-NormalizedModifiedFiles -Fact $Fact | + Where-Object { + if (-not $_.StartsWith($packagePrefix, [StringComparison]::Ordinal)) { + return $false + } + $relative = $_.Substring($packagePrefix.Length) + return ( + $relative.StartsWith('src/', [StringComparison]::Ordinal) -or + $relative.StartsWith('examples/', [StringComparison]::Ordinal) -or + ( + $relative.StartsWith('docs/', [StringComparison]::Ordinal) -and + $relative.EndsWith('.md', [StringComparison]::OrdinalIgnoreCase) + ) -or + $relative -eq 'build.rs' + ) + } + ) + if ($releaseWorthyFiles.Count -eq 0) { + throw "Selection reason 'first-release' for '$($Fact.folder)' requires a changed packaged file outside tests, benchmarks, and generated artifacts." + } + } + + $evidence = ConvertTo-CleanStringList -Values $value.evidence + if ($evidence.Count -eq 0) { + throw "Selection decision '$($Fact.folder)' must include evidence." + } + + $regression = Get-RegressionEvidence ` + -Package $Fact.folder ` + -Value $value.regressionEvidence + + return [pscustomobject]@{ + Fact = $Fact + Decision = $decision + Reason = $reason + Evidence = $evidence + RegressionEvidence = $regression.Entries + EvidenceIssues = $regression.Issues + RegressionShown = $regression.Demonstrated + } +} + +function Get-CompileFixtureKey { + param([Parameter(Mandatory = $true)][string]$Path) + + # A `.stderr`/`.stdout` file is the recorded outcome of its `.rs` sibling, so + # both collapse onto the fixture that was actually compiled. One measurement + # of that fixture therefore discharges the whole group. + $normalized = $Path.ToString().Trim().Replace('\', '/') + foreach ($extension in @('.stderr', '.stdout')) { + if ($normalized.EndsWith($extension, [StringComparison]::OrdinalIgnoreCase)) { + return $normalized.Substring(0, $normalized.Length - $extension.Length) + '.rs' + } + } + return $normalized +} + +function ConvertTo-MacroVerdictName { + param([Parameter(Mandatory = $true)][string]$ChangeType) + + switch ($ChangeType) { + 'breaking' { return 'breaking' } + 'non-breaking' { return 'nonbreaking' } + default { return 'compatible' } + } +} + +function Get-CompileEvidenceOutcome { + param( + [Parameter(Mandatory = $true)][string]$Baseline, + [Parameter(Mandatory = $true)][string]$Current + ) + + # The only mechanical reading of a compile fixture: what the same consumer + # program did before the change versus after it. + if ($Baseline -eq 'pass' -and $Current -eq 'fail') { return 'breaking' } + if ($Baseline -eq 'fail' -and $Current -eq 'pass') { return 'non-breaking' } + return 'patch' +} + +function ConvertTo-CompileEvidenceSide { + param( + [Parameter(Mandatory = $true)][string]$Package, + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Side, + [AllowNull()]$Value + ) + + $issue = "Compile evidence for '$Path' in macro contract '$Package' does not record a $Side pass/fail result with a revision and exit code." + $outcome = Get-MeasuredOutcome -Value $Value + if (-not $outcome.Complete) { + return [pscustomobject]@{ + Result = $null + Revision = $outcome.Revision + ExitCode = $outcome.ExitCode + Issue = $issue + } + } + + return [pscustomobject]@{ + Result = $outcome.Result + Revision = $outcome.Revision + ExitCode = $outcome.ExitCode + Issue = $null + } +} + +function Get-MacroCompileEvidence { + param( + [Parameter(Mandatory = $true)]$Fact, + [AllowNull()]$Value + ) + + $entries = New-Object 'System.Collections.Generic.List[object]' + $issues = New-Object 'System.Collections.Generic.List[string]' + $floor = 'patch' + $deciding = New-Object 'System.Collections.Generic.List[string]' + + # A fixture owned by a published implementation dependency is a consumer + # program for that crate, not for this macro: that crate carries its own + # release classification, and letting its fixture set this macro's floor + # would break every macro that merely depends on it. Everything else -- the + # macro itself, the facades that re-export it, and unpublished helpers with + # no release identity of their own -- can only be speaking about this macro. + $nonFloorKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($obligation in @($Fact.macroCompileFixtureChanges)) { + if ( + ($obligation.scopeRole ?? '').ToString() -eq 'implementationClosure' -and + [bool]$obligation.ownerPublished + ) { + $owner = ($obligation.ownerPackage ?? '').ToString().Replace('-', '_') + [void]$nonFloorKeys.Add( + "$owner|$(Get-CompileFixtureKey -Path ($obligation.path ?? ''))" + ) + } + } + + foreach ($item in @($Value)) { + if ($null -eq $item) { continue } + if ($item -is [string]) { + throw "Compile evidence in macro contract '$($Fact.folder)' must be an object with ownerPackage, path, baseline, and current." + } + $ownerPackage = ($item.ownerPackage ?? '').ToString().Trim() + $path = ($item.path ?? '').ToString().Trim() + if ( + [string]::IsNullOrWhiteSpace($ownerPackage) -or + [string]::IsNullOrWhiteSpace($path) + ) { + throw "Compile evidence in macro contract '$($Fact.folder)' must name ownerPackage and path." + } + + $baseline = ConvertTo-CompileEvidenceSide ` + -Package $Fact.folder -Path $path -Side 'baseline' -Value $item.baseline + $current = ConvertTo-CompileEvidenceSide ` + -Package $Fact.folder -Path $path -Side 'current' -Value $item.current + foreach ($side in @($baseline, $current)) { + if ($null -ne $side.Issue) { $issues.Add($side.Issue) | Out-Null } + } + + $outcome = $null + if ($null -ne $baseline.Result -and $null -ne $current.Result) { + $outcome = Get-CompileEvidenceOutcome ` + -Baseline $baseline.Result ` + -Current $current.Result + $evidenceKey = "$($ownerPackage.Replace('-', '_'))|$(Get-CompileFixtureKey -Path $path)" + if (-not $nonFloorKeys.Contains($evidenceKey)) { + $stronger = Get-StrongerChangeType -Left $floor -Right $outcome + if ($stronger -ne $floor) { + $floor = $stronger + $deciding.Clear() + } + if ($outcome -eq $floor -and $outcome -ne 'patch') { + $deciding.Add($path) | Out-Null + } + } + } + + $entries.Add([pscustomobject]@{ + OwnerPackage = $ownerPackage.Replace('-', '_') + Path = $path.Replace('\', '/') + Key = Get-CompileFixtureKey -Path $path + Baseline = $baseline + Current = $current + Outcome = $outcome + }) | Out-Null + } + + return [pscustomobject]@{ + Entries = $entries.ToArray() + Issues = @($issues | Sort-Object -Unique) + DerivedFloor = $floor + Deciding = @($deciding | Sort-Object -Unique) + } +} + +function Get-MacroContract { + param( + [Parameter(Mandatory = $true)]$Fact, + [Parameter(Mandatory = $true)]$Request + ) + + if (-not [bool]$Fact.procMacroOnly) { return $null } + $value = Get-RequestValue -Container $Request.macroContracts -Fact $Fact + if ($null -eq $value) { return $null } + + $verdict = if ($value -is [string]) { $value } else { $value.verdict } + $changeType = switch (($verdict ?? '').ToString().ToLowerInvariant()) { + 'compatible' { 'patch' } + 'nonbreaking' { 'non-breaking' } + 'non-breaking' { 'non-breaking' } + 'breaking' { 'breaking' } + default { + throw "Unknown macro-contract verdict '$verdict' for '$($Fact.folder)'." + } + } + + foreach ($requiredProperty in @('reviewedPackages', 'channels', 'evidence')) { + if ( + $null -eq $value.PSObject.Properties[$requiredProperty] -or + $null -eq $value.$requiredProperty + ) { + throw "Macro contract '$($Fact.folder)' must include reviewedPackages, channels, and evidence." + } + } + $reviewedPackages = ConvertTo-CleanStringList -Values $value.reviewedPackages + if ($reviewedPackages.Count -eq 0) { + throw "Macro contract '$($Fact.folder)' must include at least one reviewed package." + } + + $requiredChannels = @( + 'exportedMacros', + 'acceptedSyntax', + 'compileBehavior', + 'generatedApi', + 'generatedRuntimePaths', + 'hygiene' + ) + foreach ($channel in $requiredChannels) { + $property = $value.channels.PSObject.Properties[$channel] + if ($null -eq $property -or + $null -eq $property.Value -or + $property.Value.ToString().ToLowerInvariant() -notin @('unchanged', 'changed', 'notapplicable')) { + throw "Macro contract '$($Fact.folder)' must classify channel '$channel' as unchanged, changed, or notApplicable." + } + } + + $evidence = ConvertTo-CleanStringList -Values $value.evidence + if ($evidence.Count -eq 0) { + throw "Macro contract '$($Fact.folder)' must include evidence." + } + + $compileEvidence = Get-MacroCompileEvidence ` + -Fact $Fact ` + -Value $value.compileEvidence + + return [pscustomobject]@{ + Verdict = $verdict.ToString().ToLowerInvariant().Replace('-', '') + ChangeType = $changeType + ReviewedPackages = $reviewedPackages + Channels = $value.channels + Evidence = $evidence + CompileEvidence = $compileEvidence.Entries + EvidenceIssues = $compileEvidence.Issues + DerivedFloor = $compileEvidence.DerivedFloor + DecidingFixtures = $compileEvidence.Deciding + } +} + +function Get-MacroReviewScope { + param( + [Parameter(Mandatory = $true)]$Fact, + [Parameter(Mandatory = $true)][object[]]$Facts, + [AllowNull()]$TriggerFact + ) + + $scope = New-Object 'System.Collections.Generic.List[string]' + $scope.Add($Fact.name) | Out-Null + $closure = @($Fact.macroImplementationClosure) + $partners = @($Fact.macroRuntimePartners) + foreach ($candidate in $Facts) { + $normalizedName = $candidate.name.Replace('-', '_') + if ( + [bool]$candidate.workspaceModified -and + ($closure -contains $normalizedName -or $partners -contains $normalizedName) + ) { + $scope.Add($candidate.name) | Out-Null + } + } + if ($null -ne $TriggerFact) { + $scope.Add($TriggerFact.name) | Out-Null + } + return @($scope | Sort-Object -Unique) +} + +# The canonical review scope emitted in the plan: exactly self plus every +# modified implementation-closure member and modified runtime partner. The +# resolver validates that a supplied contract COVERS this scope, so a model may +# review more; emitting the computed scope rather than the model's list keeps the +# output identical regardless of any extra, unmodified packages a model chose to +# name. +function Get-EmittedReviewScope { + param([Parameter(Mandatory = $true)][string]$Folder) + + $fact = @($facts | Where-Object { $_.folder -eq $Folder })[0] + if ($null -eq $fact) { return @() } + return @(Get-MacroReviewScope -Fact $fact -Facts $facts -TriggerFact $null) +} + +function Test-MacroContractCoversScope { + param( + [Parameter(Mandatory = $true)]$Contract, + [Parameter(Mandatory = $true)][string[]]$Scope + ) + + $reviewed = @( + $Contract.ReviewedPackages | + Where-Object { $null -ne $_ } | + ForEach-Object { $_.ToString().Replace('-', '_') } + ) + foreach ($identifier in $Scope) { + if ($reviewed -notcontains $identifier.Replace('-', '_')) { + return $false + } + } + return $true +} + +function Get-Classification { + param( + [Parameter(Mandatory = $true)]$Fact, + [Parameter(Mandatory = $true)]$Request + ) + + $value = Get-RequestValue -Container $Request.classifications -Fact $Fact + + $manualReview = [bool]$Fact.procMacroOnly + $changeType = $null + if ($value -is [string]) { + $changeType = ConvertTo-InternalChangeType -Value $value + } elseif ($null -ne $value) { + $changeType = ConvertTo-InternalChangeType -Value $value.changeType + if ( + $null -ne $value.PSObject.Properties['manualReview'] -and + [bool]$value.manualReview -ne $manualReview + ) { + throw "manualReview for '$($Fact.folder)' is resolver-owned and must be '$manualReview'." + } + } + + if (-not [bool]$Fact.everReleased) { + return [pscustomobject]@{ + ChangeType = 'none' + ManualReview = $manualReview + } + } + + if ([string]::IsNullOrWhiteSpace($changeType)) { + if ([bool]$Fact.procMacroOnly) { + $changeType = 'patch' + $manualReview = $true + } else { + throw "Missing objective classification for published package '$($Fact.folder)'." + } + } + + $macroContract = Get-MacroContract -Fact $Fact -Request $Request + if ($null -ne $macroContract) { + if ($null -ne $value -and $changeType -ne $macroContract.ChangeType) { + throw "Classification '$changeType' for proc macro '$($Fact.folder)' conflicts with macro-contract verdict '$($macroContract.ChangeType)'." + } + $changeType = $macroContract.ChangeType + $manualReview = $true + } + + return [pscustomobject]@{ + ChangeType = $changeType + ManualReview = $manualReview + } +} + +function Find-PackageFact { + param( + [Parameter(Mandatory = $true)][string]$Identifier, + [Parameter(Mandatory = $true)][object[]]$Facts + ) + + $normalized = $Identifier.Replace('-', '_') + $matches = @( + $Facts | Where-Object { + $_.folder -eq $Identifier -or $_.name.Replace('-', '_') -eq $normalized + } + ) + if ($matches.Count -ne 1) { + throw "Release token '$Identifier' matched $($matches.Count) workspace packages." + } + return $matches[0] +} + +function Get-EntryTargetVersion { + param([Parameter(Mandatory = $true)]$Entry) + + if (-not [string]::IsNullOrWhiteSpace($Entry.RequestedPin)) { + return $Entry.RequestedPin + } + if (-not [bool]$Entry.Fact.everReleased) { + return $Entry.Fact.version + } + return Get-NextVersion ` + -currentVersion $Entry.Fact.version ` + -ChangeType $Entry.EffectiveChangeType +} + +function Assert-PinSatisfiesRequirement { + param( + [Parameter(Mandatory = $true)]$Entry, + [Parameter(Mandatory = $true)][string]$RequiredChangeType, + [Parameter(Mandatory = $true)][bool]$Force, + [Parameter(Mandatory = $true)]$Warnings + ) + + if ([string]::IsNullOrWhiteSpace($Entry.RequestedPin) -or + -not [bool]$Entry.Fact.everReleased) { + return + } + + $requiredTarget = Get-NextVersion ` + -currentVersion $Entry.Fact.version ` + -ChangeType $RequiredChangeType + if ((Compare-SemanticVersions -version1 $Entry.RequestedPin -version2 $requiredTarget) -ge 0) { + return + } + + $message = "Explicit pin '$($Entry.RequestedPin)' for '$($Entry.Fact.folder)' is below the required '$requiredTarget' ($RequiredChangeType)." + if (-not $Force) { + throw $message + } + $Warnings.Add("$message Force keeps the pin while preserving the stronger change type for further cascade decisions.") | Out-Null +} + +$factsDocument = Get-Content -LiteralPath (Resolve-Path $FactsPath) -Raw | ConvertFrom-Json +$request = Get-Content -LiteralPath (Resolve-Path $RequestPath) -Raw | ConvertFrom-Json +if ($factsDocument.schemaVersion -ne 5) { + throw 'The facts document uses an unsupported schema. Rerun release-facts.ps1.' +} +$facts = @($factsDocument.packages) +if ($facts.Count -eq 0) { + throw 'The facts document contains no workspace packages.' +} +foreach ($fact in $facts) { + foreach ($requiredProperty in @( + 'macroPublicDeps', + 'macroImplementationClosure', + 'macroRuntimePartners', + 'macroCompileFixtureChanges', + 'externalDepChanges', + 'externalExposedDeps', + 'rustImplementationChanged', + 'docCommentChanged', + 'modifiedFiles', + 'manifestDependencyScopes', + 'manifestOtherChanged', + 'workspaceModified' + )) { + if ($null -eq $fact.PSObject.Properties[$requiredProperty]) { + throw "Package fact '$($fact.folder)' is missing '$requiredProperty'. Rerun release-facts.ps1." + } + } +} + +$mode = if ([string]::IsNullOrWhiteSpace($request.mode)) { 'targeted' } else { $request.mode.ToLowerInvariant() } +if ($mode -notin @('targeted', 'changed', 'all')) { + throw "Unknown release mode '$mode'." +} + +$force = [bool]$request.force +$selectionDecisions = @{} +if ($mode -in @('changed', 'all')) { + $selectionCandidates = @( + $facts | + Where-Object { + [bool]$_.published -and + ($mode -eq 'all' -or [bool]$_.modified) + } + ) + if ($null -eq $request.selectionDecisions) { + throw "Release mode '$mode' requires selectionDecisions." + } + $candidateFolders = @($selectionCandidates.folder | Sort-Object) + $decisionKeys = @( + $request.selectionDecisions.PSObject.Properties.Name | + Sort-Object + ) + $unknownKeys = @($decisionKeys | Where-Object { $_ -notin $candidateFolders }) + if ($unknownKeys.Count -gt 0) { + throw "Selection decisions contain unknown or non-candidate packages: $($unknownKeys -join ', '). Use canonical folder identifiers." + } + $missingKeys = @($candidateFolders | Where-Object { $_ -notin $decisionKeys }) + if ($missingKeys.Count -gt 0) { + throw "Selection decisions are missing candidate packages: $($missingKeys -join ', ')." + } + foreach ($fact in $selectionCandidates) { + $selectionDecisions[$fact.folder] = Get-SelectionDecision ` + -Fact $fact ` + -Request $request ` + -Mode $mode + } +} +$tokens = @($request.tokens) +if ($tokens.Count -eq 0 -and $mode -eq 'targeted') { + throw "Release mode '$mode' requires at least one accepted package token." +} +$warnings = New-Object 'System.Collections.Generic.List[string]' +$ambiguities = New-Object 'System.Collections.Generic.List[object]' +$ambiguityKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal +) +$usedMacroContracts = @{} +$plan = @{} +$queue = New-Object 'System.Collections.Generic.Queue[string]' +$tokenFolders = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal +) + +function Get-ModifiedMacroScopeMember { + param([Parameter(Mandatory = $true)]$Fact) + + $scopeNames = @($Fact.macroImplementationClosure) + + @($Fact.macroRuntimePartners) + return @( + $facts | + Where-Object { + [bool]$_.workspaceModified -and + $scopeNames -contains $_.name.Replace('-', '_') + } + ) +} + +# The external dependency requirements a crate publishes are resolved by its +# consumers, so moving one to another compatibility line while the crate's +# public API names that dependency's types changes those types' identity for +# every consumer -- under unchanged paths, and invisibly to a +# cargo-semver-checks run that only sees this workspace's own rustdoc. +# +# The floor is deliberately narrow. A private dependency bump reaches no +# consumer, and a proc macro exports behaviour rather than foreign type +# identity, so neither can raise it; both are already excluded from +# externalExposedDeps by release-facts.ps1. +function Get-ExternalBreakingExposure { + param([Parameter(Mandatory = $true)]$Fact) + + if (-not [bool]$Fact.everReleased) { return @() } + + $exposed = @($Fact.externalExposedDeps) + return @( + @($Fact.externalDepChanges) | + Where-Object { [bool]$_.breaking -and $exposed -contains $_.name } | + Sort-Object -Property name + ) +} + +function Format-ExternalExposureProbe { + param([Parameter(Mandatory = $true)]$Changes) + + return @( + foreach ($change in @($Changes)) { + [ordered]@{ + name = $change.name + baselineReq = $change.baselineReq + currentReq = $change.currentReq + } + } + ) +} + +function Register-ExternalExposure { + param( + [Parameter(Mandatory = $true)]$Fact, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$ChangeType + ) + + $flooring = @(Get-ExternalBreakingExposure -Fact $Fact) + if ($flooring.Count -eq 0) { return } + if ( + -not [string]::IsNullOrWhiteSpace($ChangeType) -and + $script:ChangeTypeRank[$ChangeType] -ge $script:ChangeTypeRank['breaking'] + ) { + return + } + + $key = "$($Fact.folder)|externalExposureUnderclassified" + if (-not $ambiguityKeys.Add($key)) { return } + $ambiguities.Add([ordered]@{ + kind = 'externalExposureUnderclassified' + package = $Fact.folder + classified = $ChangeType + derivedFloor = 'breaking' + dependencies = @(Format-ExternalExposureProbe -Changes $flooring) + requiredInput = "classifications.$($Fact.folder)" + }) | Out-Null +} + +# A previously released ordinary library may only be classified breaking or +# nonbreaking on its own account when its own packaged Rust source actually +# changed. Doc comments, tests, benchmarks, examples, README/CHANGELOG, and +# manifest edits are not an own-diff basis for elevation above patch, and a +# re-exported macro contract or an exposed dependency bump is a cascade the +# resolver applies -- not something the crate declares about itself. The +# external-exposure lane already forces breaking up when a foreign type break is +# exposed, so it is exempt here; proc macros are classified by their contract, +# and first releases have no prior surface to break. +function Register-OwnDiffFloor { + param( + [Parameter(Mandatory = $true)]$Fact, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$ChangeType + ) + + if (-not [bool]$Fact.everReleased) { return } + if ([bool]$Fact.procMacroOnly) { return } + if ([string]::IsNullOrWhiteSpace($ChangeType)) { return } + if ($script:ChangeTypeRank[$ChangeType] -le $script:ChangeTypeRank['patch']) { return } + if ([bool]$Fact.rustImplementationChanged) { return } + if (@(Get-ExternalBreakingExposure -Fact $Fact).Count -gt 0) { return } + + $key = "$($Fact.folder)|ownClassificationUnsupported" + if (-not $ambiguityKeys.Add($key)) { return } + $ambiguities.Add([ordered]@{ + kind = 'ownClassificationUnsupported' + package = $Fact.folder + classified = $ChangeType + requiredInput = "classifications.$($Fact.folder)" + }) | Out-Null +} + +# A behaviour fix must be demonstrated, not asserted: some consumer-visible +# probe has to fail at the release baseline and pass at the current revision. +# Anything else -- no probe at all, an unchanged outcome, a newly broken probe, +# or a measurement that cannot be read -- blocks the plan instead of seeding a +# release, because an internal adaptation that preserves behaviour is +# indistinguishable from a fix once the reason is written down. +function Register-SelectionEvidence { + param( + [Parameter(Mandatory = $true)][string]$Folder, + [Parameter(Mandatory = $true)]$Decision + ) + + $exposureFlooring = @(Get-ExternalBreakingExposure -Fact $Decision.Fact) + if ($exposureFlooring.Count -gt 0 -and $Decision.Reason -ne 'breaking') { + # Declining, or accepting under any softer reason, records a judgement + # the manifest contradicts. The selection reason has to agree with the + # derived floor or the plan cannot be trusted to size the bump. + $key = "$Folder|externalExposureUnderselected" + if ($ambiguityKeys.Add($key)) { + $ambiguities.Add([ordered]@{ + kind = 'externalExposureUnderselected' + package = $Folder + decision = $Decision.Decision + reason = $Decision.Reason + derivedFloor = 'breaking' + dependencies = @(Format-ExternalExposureProbe -Changes $exposureFlooring) + requiredInput = "selectionDecisions.$Folder.reason" + }) | Out-Null + } + } + + if ($Decision.Reason -eq 'breaking') { + $classification = Get-Classification -Fact $Decision.Fact -Request $request + if ($classification.ChangeType -ne 'breaking') { + $key = "$Folder|breakingSelectionUnderclassified" + if ($ambiguityKeys.Add($key)) { + $ambiguities.Add([ordered]@{ + kind = 'breakingSelectionUnderclassified' + package = $Folder + reason = $Decision.Reason + objectiveClassification = ConvertTo-MacroVerdictName ` + -ChangeType $classification.ChangeType + requiredInput = "selectionDecisions.$Folder.reason" + }) | Out-Null + } + } + } + + if ($Decision.Reason -ne 'behavior-fix') { return } + + if (@($Decision.EvidenceIssues).Count -gt 0) { + $inconclusiveKey = "$Folder|behaviorEvidenceInconclusive" + if ($ambiguityKeys.Add($inconclusiveKey)) { + $ambiguities.Add([ordered]@{ + kind = 'behaviorEvidenceInconclusive' + package = $Folder + reason = $Decision.Reason + issues = @($Decision.EvidenceIssues) + requiredInput = "selectionDecisions.$Folder.regressionEvidence" + }) | Out-Null + } + } + + if (-not $Decision.RegressionShown) { + $undemonstratedKey = "$Folder|behaviorFixUndemonstrated" + if ($ambiguityKeys.Add($undemonstratedKey)) { + $ambiguities.Add([ordered]@{ + kind = 'behaviorFixUndemonstrated' + package = $Folder + reason = $Decision.Reason + probes = @($Decision.RegressionEvidence) + requiredInput = "selectionDecisions.$Folder.regressionEvidence" + }) | Out-Null + } + } +} + +function Register-MacroContract { + param( + [Parameter(Mandatory = $true)]$Fact, + [Parameter(Mandatory = $true)]$Contract, + [Parameter(Mandatory = $true)][string]$Trigger + ) + + $blocked = $false + + # Every fixture the facts saw change in this macro's review scope is an + # obligation: the contract must say what that consumer program did before + # the change and what it does now. Without it a compile-contract break in a + # fixture owned by a runtime partner is indistinguishable from a test-only + # edit, which is precisely how a rejected input can ship as a patch. + $obligations = @($Fact.macroCompileFixtureChanges) + if ($obligations.Count -gt 0) { + $evidenceKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($entry in @($Contract.CompileEvidence)) { + [void]$evidenceKeys.Add("$($entry.OwnerPackage)|$($entry.Key)") + } + $missing = @( + foreach ($obligation in $obligations) { + $owner = ($obligation.ownerPackage ?? '').ToString().Replace('-', '_') + $key = Get-CompileFixtureKey -Path ($obligation.path ?? '') + if (-not $evidenceKeys.Contains("$owner|$key")) { + $obligation.path + } + } + ) | Sort-Object -Unique + if ($missing.Count -gt 0) { + $missingKey = "$($Fact.folder)|macroCompileFixtureUnevidenced" + if ($ambiguityKeys.Add($missingKey)) { + $ambiguities.Add([ordered]@{ + kind = 'macroCompileFixtureUnevidenced' + package = $Fact.folder + trigger = $Trigger + fixtures = @($missing) + requiredInput = "macroContracts.$($Fact.folder).compileEvidence" + }) | Out-Null + } + $blocked = $true + } + } + + if (@($Contract.EvidenceIssues).Count -gt 0) { + $inconclusiveKey = "$($Fact.folder)|macroCompileEvidenceInconclusive" + if ($ambiguityKeys.Add($inconclusiveKey)) { + $ambiguities.Add([ordered]@{ + kind = 'macroCompileEvidenceInconclusive' + package = $Fact.folder + trigger = $Trigger + issues = @($Contract.EvidenceIssues) + requiredInput = "macroContracts.$($Fact.folder).compileEvidence" + }) | Out-Null + } + $blocked = $true + } + + # The verdict is a checked assertion, not a declaration. Measured outcomes + # set a floor; a declared verdict may sit at or above it, never below. + if ( + $script:ChangeTypeRank[$Contract.ChangeType] -lt + $script:ChangeTypeRank[$Contract.DerivedFloor] + ) { + $underKey = "$($Fact.folder)|macroVerdictUnderclassified" + if ($ambiguityKeys.Add($underKey)) { + $ambiguities.Add([ordered]@{ + kind = 'macroVerdictUnderclassified' + package = $Fact.folder + trigger = $Trigger + declaredVerdict = $Contract.Verdict + derivedVerdict = ConvertTo-MacroVerdictName -ChangeType $Contract.DerivedFloor + decidingFixtures = @($Contract.DecidingFixtures) + requiredInput = "macroContracts.$($Fact.folder).verdict" + }) | Out-Null + } + $blocked = $true + } elseif ($selectionDecisions.ContainsKey($Fact.folder)) { + # A measured compile-contract change also has to be the reason the + # package was selected, so a "behaviour fix" cannot carry a break. + $decision = $selectionDecisions[$Fact.folder] + $requiredReasons = switch ($Contract.DerivedFloor) { + 'breaking' { @('breaking') } + 'non-breaking' { @('breaking', 'nonbreaking-api', 'behavior-fix') } + default { @() } + } + if ($requiredReasons.Count -gt 0) { + $derivedName = ConvertTo-MacroVerdictName -ChangeType $Contract.DerivedFloor + if ($decision.Decision -ne 'accept') { + throw "Selection decision '$($Fact.folder)' declines a package whose compile evidence derives a '$derivedName' macro contract." + } + if ($decision.Reason -notin $requiredReasons) { + throw "Selection reason '$($decision.Reason)' for '$($Fact.folder)' conflicts with the '$derivedName' macro contract derived from its compile evidence. Use $($requiredReasons -join ' or ')." + } + } + } + + $usedMacroContracts[$Fact.folder] = $Contract + if ($blocked) { return $null } + return $Contract +} + +function Require-MacroContract { + param( + [Parameter(Mandatory = $true)]$Fact, + [AllowNull()]$TriggerFact, + [Parameter(Mandatory = $true)][string]$Trigger + ) + + $scope = @(Get-MacroReviewScope ` + -Fact $Fact ` + -Facts $facts ` + -TriggerFact $TriggerFact) + $contract = Get-MacroContract -Fact $Fact -Request $request + $triggerFolder = if ($null -eq $TriggerFact) { '' } else { $TriggerFact.folder } + $key = "$($Fact.folder)|$Trigger|$triggerFolder" + if ($null -eq $contract) { + if ($ambiguityKeys.Add($key)) { + $ambiguities.Add([ordered]@{ + kind = 'macroContractUnreviewed' + package = $Fact.folder + trigger = $Trigger + reviewScope = $scope + requiredInput = "macroContracts.$($Fact.folder)" + }) | Out-Null + } + return $null + } + + if (-not (Test-MacroContractCoversScope -Contract $contract -Scope $scope)) { + if ($ambiguityKeys.Add($key)) { + $ambiguities.Add([ordered]@{ + kind = 'macroContractIncomplete' + package = $Fact.folder + trigger = $Trigger + reviewScope = $scope + reviewed = @($contract.ReviewedPackages) + requiredInput = "macroContracts.$($Fact.folder).reviewedPackages" + }) | Out-Null + } + return $null + } + + if ( + $contract.Channels.generatedRuntimePaths.ToString().ToLowerInvariant() -eq 'changed' -and + @($Fact.macroRuntimePartners | Where-Object { $_ }).Count -eq 0 + ) { + $runtimeKey = "$($Fact.folder)|macroRuntimeUnknown" + if ($ambiguityKeys.Add($runtimeKey)) { + $ambiguities.Add([ordered]@{ + kind = 'macroRuntimeUnknown' + package = $Fact.folder + trigger = $Trigger + reviewScope = $scope + requiredInput = 'Expose the macro from its runtime facade, emit a literal workspace path, or declare package.metadata.oxidizer_release.macro_runtime.' + }) | Out-Null + } + return $null + } + + return Register-MacroContract ` + -Fact $Fact ` + -Contract $contract ` + -Trigger $Trigger +} + +function Format-SelectionDecisionOutput { + @( + $selectionDecisions.GetEnumerator() | + Sort-Object Key | + ForEach-Object { + [ordered]@{ + package = $_.Key + decision = $_.Value.Decision + reason = $_.Value.Reason + evidence = @($_.Value.Evidence) + regressionEvidence = @($_.Value.RegressionEvidence) + } + } + ) +} + +function Format-MacroContractOutput { + @( + $usedMacroContracts.GetEnumerator() | + Sort-Object Key | + ForEach-Object { + [ordered]@{ + package = $_.Key + verdict = $_.Value.Verdict + derivedVerdict = ConvertTo-MacroVerdictName -ChangeType $_.Value.DerivedFloor + reviewed = @(Get-EmittedReviewScope -Folder $_.Key) + evidence = @($_.Value.Evidence) + } + } + ) +} + +function Write-BlockedPlan { + [ordered]@{ + status = 'blocked' + mode = $mode + selectionDecisions = @(Format-SelectionDecisionOutput) + releases = @() + macroContracts = @(Format-MacroContractOutput) + ambiguities = @($ambiguities | Sort-Object package, kind, trigger) + warnings = @($warnings) + } | ConvertTo-Json -Depth 10 +} + +# Selection evidence is graded before any token is expanded, so a plan whose +# reasons are not demonstrated can never reach the release loop. +foreach ($selectionFolder in @($selectionDecisions.Keys | Sort-Object)) { + Register-SelectionEvidence ` + -Folder $selectionFolder ` + -Decision $selectionDecisions[$selectionFolder] +} + +foreach ($tokenValue in $tokens) { + $token = $tokenValue.ToString() + $parts = $token -split '@', 2 + $fact = Find-PackageFact -Identifier $parts[0] -Facts $facts + if (-not [bool]$fact.published) { + throw "Package '$($fact.folder)' is not publishable." + } + if (-not $tokenFolders.Add($fact.folder)) { + throw "Package '$($fact.folder)' appears more than once in the release tokens." + } + if ($mode -in @('changed', 'all') -and -not $selectionDecisions.ContainsKey($fact.folder)) { + throw "Release token '$($fact.folder)' is not a candidate in $mode mode." + } + if ($mode -in @('changed', 'all') -and $selectionDecisions[$fact.folder].Decision -ne 'accept') { + throw "Release token '$($fact.folder)' conflicts with its decline selection decision." + } + + $classification = Get-Classification -Fact $fact -Request $request + Register-ExternalExposure -Fact $fact -ChangeType $classification.ChangeType + Register-OwnDiffFloor -Fact $fact -ChangeType $classification.ChangeType + $requestedChangeType = 'none' + $requestedPin = $null + if ($parts.Count -eq 2) { + try { + $requestedChangeType = ConvertTo-InternalChangeType -Value $parts[1] + } catch { + [void](Split-SemanticVersion -version $parts[1]) + $requestedPin = $parts[1] + if ((Compare-SemanticVersions -version1 $requestedPin -version2 $fact.version) -le 0) { + throw "Explicit pin '$requestedPin' for '$($fact.folder)' must be strictly greater than '$($fact.version)'." + } + } + } + + $effectiveChangeType = Get-StrongerChangeType ` + -Left $classification.ChangeType ` + -Right $requestedChangeType + if (-not [string]::IsNullOrWhiteSpace($requestedPin)) { + $pinChangeType = Get-ChangeTypeFromVersions ` + -oldVersion $fact.version ` + -newVersion $requestedPin + $effectiveChangeType = Get-StrongerChangeType ` + -Left $effectiveChangeType ` + -Right $pinChangeType + } + if ($effectiveChangeType -eq 'none') { + $effectiveChangeType = 'patch' + } + + $macroContract = Get-MacroContract -Fact $fact -Request $request + if ([bool]$fact.procMacroOnly) { + $modifiedScope = @(Get-ModifiedMacroScopeMember -Fact $fact) + $needsMacroReview = + [bool]$fact.modified -or + $modifiedScope.Count -gt 0 -or + $classification.ChangeType -ne 'patch' -or + $requestedChangeType -in @('non-breaking', 'breaking') -or + -not [string]::IsNullOrWhiteSpace($requestedPin) + if ($needsMacroReview) { + $trigger = if ([bool]$fact.modified) { + 'macroPackageModified' + } elseif ($modifiedScope.Count -gt 0) { + 'implementationClosureModified' + } else { + 'macroContractChangeRequested' + } + $macroContract = Require-MacroContract ` + -Fact $fact ` + -TriggerFact $null ` + -Trigger $trigger + if ($null -eq $macroContract) { continue } + } elseif ($null -ne $macroContract) { + $macroContract = Register-MacroContract ` + -Fact $fact ` + -Contract $macroContract ` + -Trigger 'macroContractSupplied' + if ($null -eq $macroContract) { continue } + } + if ( + $null -ne $macroContract -and + $script:ChangeTypeRank[$requestedChangeType] -gt + $script:ChangeTypeRank[$macroContract.ChangeType] + ) { + throw "Requested change '$requestedChangeType' for proc macro '$($fact.folder)' conflicts with its '$($macroContract.ChangeType)' contract verdict. Use an exact version pin for a compatible version-line change." + } + } + + $entry = [pscustomobject]@{ + Fact = $fact + Source = 'user' + RequestedPin = $requestedPin + EffectiveChangeType = $effectiveChangeType + TargetVersion = $null + ManualReview = [bool]$classification.ManualReview + MacroContractReviewed = $null -ne $macroContract + ContractBreaking = [bool]( + [bool]$fact.procMacroOnly -and + ( + ($null -ne $macroContract -and $macroContract.ChangeType -eq 'breaking') -or + ($null -eq $macroContract -and + ($classification.ChangeType -eq 'breaking' -or $requestedChangeType -eq 'breaking')) + ) + ) + Reasons = @{} + } + Assert-PinSatisfiesRequirement ` + -Entry $entry ` + -RequiredChangeType $effectiveChangeType ` + -Force $force ` + -Warnings $warnings + $entry.TargetVersion = Get-EntryTargetVersion -Entry $entry + $plan[$fact.folder] = $entry + $queue.Enqueue($fact.folder) +} + +if ($mode -in @('changed', 'all')) { + foreach ($candidate in $selectionDecisions.GetEnumerator()) { + if ( + $candidate.Value.Decision -eq 'accept' -and + -not $tokenFolders.Contains($candidate.Key) + ) { + throw "Accepted selection decision '$($candidate.Key)' is missing a release token." + } + } + + # A declined proc macro never reaches the token loop, so its compile-fixture + # obligations would otherwise go unreviewed. Requiring the contract here + # keeps the decline honest without forcing a release: a measured + # fail -> fail outcome leaves the decline standing, while a measured break + # cannot be declined at all. + foreach ($candidate in $selectionDecisions.GetEnumerator()) { + if ($candidate.Value.Decision -ne 'decline') { continue } + $candidateFact = @($facts | Where-Object { $_.folder -eq $candidate.Key })[0] + if ($null -eq $candidateFact -or -not [bool]$candidateFact.procMacroOnly) { + continue + } + if (@($candidateFact.macroCompileFixtureChanges).Count -eq 0) { continue } + [void](Require-MacroContract ` + -Fact $candidateFact ` + -TriggerFact $null ` + -Trigger 'macroCompileFixtureChanged') + } +} + +if ($ambiguities.Count -gt 0) { + Write-BlockedPlan + return +} + +while ($queue.Count -gt 0) { + $dependencyFolder = $queue.Dequeue() + $dependencyEntry = $plan[$dependencyFolder] + $dependencyName = $dependencyEntry.Fact.name.Replace('-', '_') + $dependencyVersionBreaking = + [bool]$dependencyEntry.Fact.everReleased -and + (Compare-SemanticVersions ` + -version1 $dependencyEntry.TargetVersion ` + -version2 $dependencyEntry.Fact.version) -ne 0 -and + (Test-IsBreakingChange ` + -oldVersion $dependencyEntry.Fact.version ` + -ChangeType $dependencyEntry.EffectiveChangeType) + $dependencyContractBreaking = + [bool]$dependencyEntry.Fact.procMacroOnly -and + [bool]$dependencyEntry.ContractBreaking + + $dependents = @( + $facts | + Where-Object { + [bool]$_.published -and + [bool]$_.everReleased -and + $_.folder -ne $dependencyFolder -and + ( + @($_.deps) -contains $dependencyName -or + ( + -not [bool]$dependencyEntry.Fact.procMacroOnly -and + $dependencyVersionBreaking -and + @($_.exposedDeps) -contains $dependencyName + ) -or + ( + [bool]$dependencyEntry.Fact.procMacroOnly -and + $dependencyContractBreaking -and + @($_.macroPublicDeps) -contains $dependencyName + ) + ) + } | + Sort-Object folder + ) + + foreach ($dependentFact in $dependents) { + $classification = Get-Classification -Fact $dependentFact -Request $request + Register-ExternalExposure ` + -Fact $dependentFact ` + -ChangeType $classification.ChangeType + Register-OwnDiffFloor ` + -Fact $dependentFact ` + -ChangeType $classification.ChangeType + $macroContract = $null + if ([bool]$dependentFact.procMacroOnly) { + $modifiedScope = @(Get-ModifiedMacroScopeMember -Fact $dependentFact) + $needsMacroReview = + $dependencyVersionBreaking -or + [bool]$dependentFact.modified -or + $modifiedScope.Count -gt 0 -or + $classification.ChangeType -ne 'patch' + if ($needsMacroReview) { + $macroContract = Require-MacroContract ` + -Fact $dependentFact ` + -TriggerFact $dependencyEntry.Fact ` + -Trigger 'implementationDependencyChanged' + if ($null -eq $macroContract) { continue } + } else { + $macroContract = Get-MacroContract ` + -Fact $dependentFact ` + -Request $request + if ($null -ne $macroContract) { + $macroContract = Register-MacroContract ` + -Fact $dependentFact ` + -Contract $macroContract ` + -Trigger 'macroContractSupplied' + } + } + } + + $isDirectDependent = @($dependentFact.deps) -contains $dependencyName + if ([bool]$dependentFact.procMacroOnly) { + $edgeClass = 'macroImplementation' + $edgeBreaking = + $null -ne $macroContract -and + $macroContract.ChangeType -eq 'breaking' + $judgment = if ($edgeBreaking) { + 'contractBreaking' + } elseif ($null -ne $macroContract) { + 'contractCompatible' + } else { + 'patchFloor' + } + $judgmentSource = if ($null -ne $macroContract) { + 'macroContracts' + } else { + 'dependencyRequirement' + } + } elseif ([bool]$dependencyEntry.Fact.procMacroOnly) { + $macroIsPublic = @($dependentFact.macroPublicDeps) -contains $dependencyName + $edgeClass = if ($macroIsPublic) { 'macroPublic' } else { 'macroPrivate' } + $edgeBreaking = $dependencyContractBreaking -and $macroIsPublic + $judgment = if ($edgeBreaking) { + 'contractBreaking' + } elseif ($macroIsPublic -and [bool]$dependencyEntry.MacroContractReviewed) { + 'contractCompatible' + } elseif ($macroIsPublic) { + 'patchFloor' + } else { + 'privateDependency' + } + $judgmentSource = if ([bool]$dependencyEntry.MacroContractReviewed) { + 'macroContracts' + } else { + 'dependencyRequirement' + } + } else { + $exposesDependency = + ($isDirectDependent -and [bool]$dependentFact.exposureUnknown) -or + (@($dependentFact.exposedDeps) -contains $dependencyName) + $edgeClass = 'type' + $edgeBreaking = $dependencyVersionBreaking -and $exposesDependency + $judgment = if ($edgeBreaking) { 'typeExposed' } else { 'encapsulated' } + $judgmentSource = 'releaseFacts' + } + + $cascadeChangeType = Get-StrongerChangeType ` + -Left 'patch' ` + -Right $classification.ChangeType + if ($edgeBreaking) { + $cascadeChangeType = 'breaking' + } + + $isNew = -not $plan.ContainsKey($dependentFact.folder) + if ($isNew) { + $dependentEntry = [pscustomobject]@{ + Fact = $dependentFact + Source = 'cascade' + RequestedPin = $null + EffectiveChangeType = $cascadeChangeType + TargetVersion = $null + ManualReview = [bool]$classification.ManualReview + MacroContractReviewed = $null -ne $macroContract + ContractBreaking = [bool]( + [bool]$dependentFact.procMacroOnly -and + $null -ne $macroContract -and + $macroContract.ChangeType -eq 'breaking' + ) + Reasons = @{} + } + $dependentEntry.TargetVersion = Get-EntryTargetVersion -Entry $dependentEntry + $plan[$dependentFact.folder] = $dependentEntry + } else { + $dependentEntry = $plan[$dependentFact.folder] + } + + $dependentEntry.Reasons[$dependencyFolder] = [pscustomobject]@{ + Target = $dependencyEntry.Fact.name + Version = $dependencyEntry.TargetVersion + Breaking = [bool]$edgeBreaking + EdgeClass = $edgeClass + Judgment = $judgment + JudgmentSource = $judgmentSource + } + + $stronger = Get-StrongerChangeType ` + -Left $dependentEntry.EffectiveChangeType ` + -Right $cascadeChangeType + $strengthened = $stronger -ne $dependentEntry.EffectiveChangeType + if ($strengthened) { + $dependentEntry.EffectiveChangeType = $stronger + if ( + [bool]$dependentFact.procMacroOnly -and + $null -ne $macroContract -and + $macroContract.ChangeType -eq 'breaking' + ) { + $dependentEntry.ContractBreaking = $true + } + Assert-PinSatisfiesRequirement ` + -Entry $dependentEntry ` + -RequiredChangeType $stronger ` + -Force $force ` + -Warnings $warnings + $dependentEntry.TargetVersion = Get-EntryTargetVersion -Entry $dependentEntry + } + + if ($isNew -or $strengthened) { + $queue.Enqueue($dependentFact.folder) + } + } + + if (-not [bool]$dependencyEntry.Fact.procMacroOnly -and $dependencyVersionBreaking) { + $runtimeMacros = @( + $facts | + Where-Object { + [bool]$_.published -and + [bool]$_.everReleased -and + [bool]$_.procMacroOnly -and + @($_.macroRuntimePartners) -contains $dependencyName + } | + Sort-Object folder + ) + foreach ($macroFact in $runtimeMacros) { + $macroContract = Require-MacroContract ` + -Fact $macroFact ` + -TriggerFact $dependencyEntry.Fact ` + -Trigger 'generatedRuntimeChanged' + if ($null -eq $macroContract -or $macroContract.ChangeType -eq 'patch') { + continue + } + + $classification = Get-Classification -Fact $macroFact -Request $request + Register-ExternalExposure ` + -Fact $macroFact ` + -ChangeType $classification.ChangeType + $cascadeChangeType = Get-StrongerChangeType ` + -Left $classification.ChangeType ` + -Right $macroContract.ChangeType + $isNew = -not $plan.ContainsKey($macroFact.folder) + if ($isNew) { + $macroEntry = [pscustomobject]@{ + Fact = $macroFact + Source = 'cascade' + RequestedPin = $null + EffectiveChangeType = $cascadeChangeType + TargetVersion = $null + ManualReview = $true + MacroContractReviewed = $true + ContractBreaking = $macroContract.ChangeType -eq 'breaking' + Reasons = @{} + } + $macroEntry.TargetVersion = Get-EntryTargetVersion -Entry $macroEntry + $plan[$macroFact.folder] = $macroEntry + } else { + $macroEntry = $plan[$macroFact.folder] + } + + $macroEntry.Reasons[$dependencyFolder] = [pscustomobject]@{ + Target = $dependencyEntry.Fact.name + Version = $dependencyEntry.TargetVersion + Breaking = $macroContract.ChangeType -eq 'breaking' + EdgeClass = 'macroRuntime' + Judgment = if ($macroContract.ChangeType -eq 'breaking') { + 'contractBreaking' + } else { + 'contractNonbreaking' + } + JudgmentSource = 'macroContracts' + } + + $stronger = Get-StrongerChangeType ` + -Left $macroEntry.EffectiveChangeType ` + -Right $cascadeChangeType + $strengthened = $stronger -ne $macroEntry.EffectiveChangeType + if ($strengthened) { + $macroEntry.EffectiveChangeType = $stronger + $macroEntry.ContractBreaking = $macroContract.ChangeType -eq 'breaking' + $macroEntry.TargetVersion = Get-EntryTargetVersion -Entry $macroEntry + } + if ($isNew -or $strengthened) { + $queue.Enqueue($macroFact.folder) + } + } + } +} + +if ($ambiguities.Count -gt 0) { + Write-BlockedPlan + return +} + +$indegree = @{} +foreach ($folder in $plan.Keys) { + $indegree[$folder] = 0 +} +foreach ($folder in $plan.Keys) { + foreach ($dependencyName in @($plan[$folder].Fact.deps | Sort-Object -Unique)) { + $dependency = $plan.Values | + Where-Object { $_.Fact.name.Replace('-', '_') -eq $dependencyName } | + Select-Object -First 1 + if ($null -ne $dependency) { + $indegree[$folder]++ + } + } +} + +$orderedFolders = New-Object 'System.Collections.Generic.List[string]' +while ($orderedFolders.Count -lt $plan.Count) { + $ready = @( + $indegree.Keys | + Where-Object { $indegree[$_] -eq 0 -and -not $orderedFolders.Contains($_) } | + Sort-Object + ) + if ($ready.Count -eq 0) { + throw 'The release set contains a dependency cycle and cannot be topologically ordered.' + } + + foreach ($folder in $ready) { + $orderedFolders.Add($folder) | Out-Null + $releasedName = $plan[$folder].Fact.name.Replace('-', '_') + foreach ($candidate in $plan.Keys) { + if ($orderedFolders.Contains($candidate)) { continue } + if (@($plan[$candidate].Fact.deps) -contains $releasedName) { + $indegree[$candidate]-- + } + } + } +} + +$releases = foreach ($folder in $orderedFolders) { + $entry = $plan[$folder] + [ordered]@{ + folder = $entry.Fact.folder + name = $entry.Fact.name + from = $entry.Fact.version + to = $entry.TargetVersion + changeType = $entry.EffectiveChangeType.Replace('-', '') + source = $entry.Source + manualReview = [bool]$entry.ManualReview + contractBreaking = [bool]$entry.ContractBreaking + cascadeReasons = @( + $entry.Reasons.Values | + Sort-Object Target | + ForEach-Object { + [ordered]@{ + target = $_.Target + version = $_.Version + breaking = [bool]$_.Breaking + edgeClass = $_.EdgeClass + judgment = $_.Judgment + judgmentSource = $_.JudgmentSource + } + } + ) + } +} + +[ordered]@{ + status = 'resolved' + mode = $mode + selectionDecisions = @(Format-SelectionDecisionOutput) + releases = @($releases) + macroContracts = @(Format-MacroContractOutput) + ambiguities = @() + warnings = @($warnings) +} | ConvertTo-Json -Depth 8 diff --git a/AGENTS.md b/AGENTS.md index 6967243ed..a3a75cca0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ The spell checker dictionary is in the `.spelling` file, one word per line in ar ## Changelogs -The changelogs are updated by `scripts/release-packages.ps1` at release time, based on Git history. It is not necessary to make manual edits +The changelogs are updated by the `release-packages` skill at release time, based on Git history. It is not necessary to make manual edits to the changelogs, though you are permitted to do so if explicitly instructed. ## Releasing Packages @@ -32,7 +32,7 @@ See [docs/releasing.md](docs/releasing.md) for the release tooling reference: glossary (direct/transitive dependent vs dependency, cascade direction, change type vs version component, release set, pending release, elevation), the cascade-organisation invariants, and the -workflow for `scripts/release-packages.ps1`. +workflow for `.github/skills/release-packages/SKILL.md`. ## Packaging diff --git a/README.md b/README.md index 215727e71..834cc8f7d 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,7 @@ The `add-crate` script does the following: `cargo-doc2readme`](https://crates.io/crates/cargo-doc2readme) with a set of appropriate CI badges. -- Creates an empty `CHANGELOG.md` file for the crate, which will later get populated by the `scripts\release-packages.ps1` - script. +- Creates an empty `CHANGELOG.md` file for the crate, which will later be populated by the `release-packages` skill. - Creates placeholder `logo.png` and `favicon.ico` files for the crate, which you're expected to replace with legit crab-themed @@ -107,10 +106,11 @@ this simple process: 2. Create a branch off of main. -3. Run `./scripts/release-packages.ps1 -Packages '@'` to update versions and changelogs. - The change type for each package is one of `breaking`, `nonbreaking`, `patch`, or an explicit version like - `1.0.0`. To release several crates together, list them all in the same `-Packages` argument - (for example, `'foo@nonbreaking','bar@patch'`); the script plans the entire release up-front. +3. Invoke the repository's `release-packages` skill with ``. The + skill determines the change type from repository history and source review. + Optional `@breaking`, `@nonbreaking`, `@patch`, or explicit-version suffixes + set lower bounds or pins when needed. Multiple crate names form one release + plan. 4. Create a PR like normal to push changes out. @@ -128,8 +128,8 @@ automation processes: generates the `README.md` file using a shared template. A pull request gate ensures the `README.md` file always reflects the latest crate documentation. -- The `CHANGELOG.md` file in each crate's directory is auto-generated from the commits to a crate's directory by the - `scripts/release-packages.ps1` script. +- The `CHANGELOG.md` file in each crate's directory is generated from the commits + to that crate by the `release-packages` skill. To generate documentation locally with all features enabled (including feature-gated items), run: diff --git a/crates/templated_uri_macros_impl/Cargo.toml b/crates/templated_uri_macros_impl/Cargo.toml index 40df96646..122551991 100644 --- a/crates/templated_uri_macros_impl/Cargo.toml +++ b/crates/templated_uri_macros_impl/Cargo.toml @@ -20,6 +20,9 @@ repository = "https://github.com/microsoft/oxidizer/tree/main/crates/templated_u [package.metadata.docs.rs] all-features = true +[package.metadata.cargo_check_external_types] +allowed_external_types = ["proc_macro2::*"] + [dependencies] chumsky = { workspace = true, features = ["std"] } darling = { workspace = true } diff --git a/docs/releasing.md b/docs/releasing.md index c261b1a18..5d367e75a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,604 +1,431 @@ # Releasing Oxidizer Packages -This document is the reference for the human-driven release tooling in -`scripts/`: - -- `scripts/release-packages.ps1` — interactive release driver. Picks one - of three mutually-exclusive target-selection modes: - - - `-Packages '@', ...` — the caller supplies the - full release plan up-front as `name@change-spec` tokens. - - `-Changed` — guided walk through every workspace package with - unreleased modifications (changes newer than the package's last - `version =` / `publish =` commit). The script prompts for a - per-package decision (view diff / ignore / release as breaking, - non-breaking, or patch). Note: the change scan only sees files - under `crates//`; modifications elsewhere in the - repository (e.g. the workspace-level `Cargo.toml`, `.cargo/`, - `deny.toml`, or shared CI configuration) do NOT surface a package - even if they affect how it builds or behaves — use `-All` or - `-Packages` to cover that case. - - `-All` — guided walk through every publishable workspace package, - even ones with no on-disk modifications. Use to force-walk the - workspace when a refactor may have touched packages the change scan - misses, or to coordinate a multi-package release after an internal - cleanup. - - All three modes are interactive — even `-Packages` may prompt for - elevation review when modified-but-unreleased dependencies of the - requested packages are detected. The script must be run from an - interactive terminal. - - In every mode the same pipeline runs: plan resolution, - cascade toward dependents, an interactive elevation review for any - modified-but-unreleased dependencies, a final plan display, then - atomic application of all version-number increments, changelog - updates, README regeneration, and `Cargo.toml` rewrites. - -Maintainers SHOULD read the **Glossary** below before making changes to -the release tooling; the rest of the codebase, the PR comments, the -script output, and the unit tests all use these terms with the precise -meanings defined here. - ---- - -## Glossary - -- **Direct dependency** — a workspace package listed under another - package's `[dependencies]` (or `[dev-dependencies]` / - `[build-dependencies]`) in its `Cargo.toml`. If `bytesbuf_io` lists - `bytesbuf`, then `bytesbuf` is a direct dependency of `bytesbuf_io`. - -- **Transitive dependency** — a workspace package reachable through - some chain of direct-dependency edges. Every direct dependency is also - a transitive dependency. - -- **Direct dependent** — the inverse of direct dependency. If - `bytesbuf_io` lists `bytesbuf`, then `bytesbuf_io` is a direct - dependent of `bytesbuf`. - -- **Transitive dependent** — the inverse of transitive dependency: any - package reachable through a chain of dependent edges. Every direct - dependent is also a transitive dependent. - - > Avoid "upstream" / "downstream" — they are ambiguous (their meaning - > depends on which way the reader visualises the graph). Always use the - > dependency/dependent vocabulary above. - -- **Cascade toward dependents** — the automatic version-number-increment - propagation that happens when a released package's transitive - dependents need to also be released because they (transitively) consume - it. The planner walks the user-supplied release plan, computes the - transitive dependents of each user-source release, and adds - cascade-source entries to the plan so the dependents are also released. - -- **Cascade toward dependencies** — the inverse: when a package being - released has direct dependencies with unreleased modifications, the - release plan does NOT automatically pull them in. Instead the planner - surfaces them to the caller during the review step, who decides - whether to release them too or leave them out. The surfaced - dependencies that the caller accepts join the release plan as ordinary - user-source releases — there is no separate "dependency-cascade-source" - release kind because the caller is always the decision-maker for any - pulled-in dependency. - -- **Change type** — the *semantic intent* of a release: - `breaking` / `nonbreaking` / `patch`. This is what releasers reason - about. In the `-Packages` tokens it appears as the part after `@`, e.g. - `bytesbuf@breaking`. - -- **Change spec** — the value of the part after `@` in a `-Packages` - token. A change spec is either a change type (`breaking`, - `nonbreaking`, `patch`) or an explicit semver version like `1.0.0` or - `2.5.0`. Change types are translated into concrete versions using the - version-increment rules below. Explicit versions pass through verbatim - and must be strictly greater than the package's current on-disk - version. - -- **Version component** — a *position* in the SemVer string - `major.minor.patch` (the three integers in `x.y.z`). These names are - positional, not semantic. The same change type maps to different - version components depending on the current version: - - | Current | breaking | nonbreaking | patch | - |-----------|---------------|------------------|------------------| - | `x.y.z`, x≥1 | `(x+1).0.0` | `x.(y+1).0` | `x.y.(z+1)` | - | `0.y.z`, y≥1 | `0.(y+1).0` | `0.y.(z+1)` | `0.y.(z+1)`† | - | `0.0.z` | `0.0.(z+1)` | `0.0.(z+1)` | `0.0.(z+1)` | - - † On `0.x.y` a `patch` change spec produces the same numeric outcome - as `nonbreaking`. The planner does not reject this — the caller may - still record the intent as `patch` so it shows up that way in the - release plan and commit message. - - Do not call a `0.4.1 → 0.5.0` increment a "major version change" — the - value of the *major component* (0) did not change, even though the - change is breaking under Cargo's 0.x SemVer rules. - -- **Release set** — the set of workspace packages a single release will - publish to crates.io. The local driver (`release-packages.ps1`) - materialises it as the **resolved release set**: the caller's - `-Packages` tokens plus everything pulled in by the cascade toward - dependents. - -- **Pending release** — a member of the release set that has not yet - reached crates.io. Committed-vs-uncommitted is irrelevant: a - version-number increment sitting in your working tree, a - committed-but-unpushed increment, and a merged-but-untagged increment - all count the same. - -- **Resolved release set** — the per-invocation, in-memory result of - plan resolution. It is a hashtable keyed by package folder where each - entry records the package's source (`user` or `cascade`), the - effective change type (after cascade-driven upgrade), the effective - target version, and the list of cascade reasons (which user-source - releases caused the cascade). The resolved release set is the - planner's source of truth for the rest of the run. - -- **User-source release** — a release plan entry derived directly from a - `-Packages` token, OR added by the caller's "release this" choice - during dep-scan review. Either way, the caller explicitly asked for - this release. - -- **Cascade-source release** — a release plan entry added by the - cascade-toward-dependents walk during plan resolution. The caller did - not list this package in `-Packages` and did not accept it during - dep-scan review; it was added because it is a transitive dependent of - a user-source release. - ---- - -## Bundled-input release model - -Every invocation of `release-packages.ps1` describes a *complete release -plan*. The planner reads the entire plan up-front (the `-Packages` -argument is the entire input — there is no base ref), resolves the -cascade toward dependents, surfaces any modified-but-unreleased -dependencies for review, and then applies all version-number increments, -changelog updates, and `Cargo.toml` rewrites in one shot. A second -invocation is treated as a fresh, independent release plan — there is -no notion of "adding to a previous run". - -Version arithmetic anchors on what is currently in each `Cargo.toml` on -disk. The planner does not consult `git` for prior versions; it -increments from the value it reads right now. Consequently, if you -re-run on the same branch after a prior run already increased a version, -the new run will increment *on top of* that change — see -[Re-running on the same branch](#re-running-on-the-same-branch). - -If you need to re-plan (for example because you accepted a release -during review that you now want to remove), use `git reset` / -`git restore` to revert the on-disk state and re-run the script with -the corrected `-Packages` argument. - -### `-Packages` token syntax - -Each token has the form `@`: - -- `` is the package name as it appears in `crates//Cargo.toml`. -- `` is one of: - - `breaking`, `nonbreaking`, `patch` — the change type. The planner - computes the target version from the package's current version on - disk using the version-increment rules in the **Version component** - glossary entry. - - An explicit semver (e.g. `1.0.0`, `2.5.0`, `0.10.0`) — used - verbatim. Must be strictly greater than the current on-disk - version. There is no special handling for any particular version - value; `1.0.0` is just another explicit pin. - -Examples: +Oxidizer package releases are planned and applied by the repository skill at +`.github/skills/release-packages/SKILL.md`. The skill owns orchestration and +source-diff judgment; small PowerShell helpers own deterministic mechanics. -```powershell -# Single package, non-breaking change. -./scripts/release-packages.ps1 -Packages 'bytesbuf@nonbreaking' +## Architecture + +| Component | Responsibility | +|---|---| +| `release-facts.ps1` | Workspace packages, versions, dependencies, type exposure, macro relationships, release baselines, and modifications | +| `resolve-plan.ps1` | Tokens, SemVer arithmetic, pins, type/macro contract cascades, ambiguities, and topological ordering | +| `apply-plan.ps1` | Version writes, changelogs, README generation, Cargo validation, and rollback | +| `release-changelog.ps1` | One deterministic changelog | +| `scripts/ci/semver-report.ps1` | CI report for version changes already present in a PR | + +Shared functions remain in `scripts/lib/releasing.ps1` and +`scripts/lib/changelog.ps1` because CI also consumes them. + +The model may classify source changes and procedural macro contracts. It must not +reimplement version arithmetic, dependency closure, Cargo.toml editing, or +rollback. + +## Terminology + +- **Dependency**: a package consumed by another package. +- **Dependent**: a package that consumes another package. +- **Direct**: one dependency edge away. +- **Transitive**: reachable through one or more dependency edges. +- **Change type**: `breaking`, `nonbreaking`, or `patch`. +- **Release set**: explicit releases plus published dependents pulled in by the + cascade. +- **User-source release**: selected explicitly during review. +- **Cascade-source release**: added because one of its dependencies is released. +- **First release**: a publishable package with no matching release tag; + `everReleased`, not `hasBaseline`, identifies this state. + +Avoid *upstream* and *downstream* because their direction is ambiguous. + +## Modes and tokens + +The skill supports: + +- **targeted**: explicit package tokens; +- **changed**: review every publishable package with unreleased changes; +- **all**: review every publishable package. + +A token is: + +```text +name +name@breaking +name@nonbreaking +name@patch +name@ +``` + +Change types are lower bounds. An explicit version is an exact pin and must be +strictly greater than the package's current version under SemVer precedence. +Build metadata does not affect precedence. + +If every changed or all candidate is declined, the result is an empty plan and +nothing is written. + +## Version rules -# Two packages: one breaking, one patch. -./scripts/release-packages.ps1 -Packages 'bytesbuf@breaking','bytesbuf_io@patch' +| Current | breaking | nonbreaking | patch | +|---|---|---|---| +| `x.y.z`, `x >= 1` | `(x+1).0.0` | `x.(y+1).0` | `x.y.(z+1)` | +| `0.y.z`, `y >= 1` | `0.(y+1).0` | `0.y.(z+1)` | `0.y.(z+1)` | +| `0.0.z` | `0.0.(z+1)` | `0.0.(z+1)` | `0.0.(z+1)` | -# Pin one package to 1.0.0 and another to an explicit version. -./scripts/release-packages.ps1 -Packages 'foo@1.0.0','bar@2.5.0' +On `0.y.z`, nonbreaking and patch retain distinct intent despite producing the +same version. Every `0.0.z` transition is breaking under Cargo compatibility. +See the skill's `references/version-rules.md` for the executable resolver's +canonical rules. + +## Release facts and exposure + +`release-facts.ps1` emits, for each package: + +```text +folder, name, version, published, procMacroOnly, hasLibraryTarget, +deps, exposedDeps, macroPublicDeps, macroImplementationClosure, +macroRuntimePartners, macroCompileFixtureChanges, externalDepChanges, +externalExposedDeps, exposureUnknown, +baselineSha, hasBaseline, everReleased, modified, modifiedFiles, +modifiedFileCount, manifestDependencyScopes, manifestOtherChanged, +rustImplementationChanged, docCommentChanged, workspaceModified ``` -### Cascade-toward-dependents and topological consistency - -After parsing the tokens, the planner walks the workspace dependency -graph forward from every user-source release and adds each transitive -published dependent as a cascade-source release. For ordinary library -packages, the required change type — both for directly-requested -(user-source) packages and cascade-pulled dependents — is derived by running -[`cargo semver-checks`](https://crates.io/crates/cargo-semver-checks) -against each crate's **previous version-bump commit in git history** — -the most recent commit that changed the crate's `[package] version`, -supplied to the tool as `--baseline-rev `. cargo-semver-checks -rebuilds the baseline rustdoc from the crate's source at that commit, so -**no registry access is required** and the check behaves identically for -open-source (crates.io) and enterprise/offline consumers. The current -working-tree API is analysed, so a coordinated release's in-progress -edits are reflected rather than only what has been committed. That is -necessary but not sufficient on its own — see the exposed-dependency -cascade below, which covers the breaks a rustdoc diff cannot show. - -Versioning is treated as a **source-level** concern: the baseline is the -version the repository last *declared*, regardless of whether it was ever -published anywhere. This is what lets one workflow serve both public and -private/enterprise environments (which cannot reach crates.io and whose -published content lags the source), and it means an aborted release that -bumped a crate to `4.0.0` without publishing is still the baseline the -next change is measured against. - -`cargo semver-checks` is combined with an exposed-dependency cascade. -Rustdoc comparison cannot detect that an otherwise-unchanged signature -now names a type from an incompatible version of an external crate. The -planner therefore also consults -`[package.metadata.cargo_check_external_types].allowed_external_types`. -If the dependency's planned version transition is breaking and the -dependent allows that dependency's types in its public API, the dependent -is floored at `breaking`. The planner repeats this check to a fixpoint so -the result propagates through chains such as `bytesbuf` → `bytesbuf_io` → -another facade. - -Two kinds of edge are considered, and they treat missing evidence -differently: - -- **Direct dependency edges** fail closed. Absent metadata, a malformed - entry, or a wildcard root all count as possible exposure, because an - unknown must not ship a break as compatible. - -- **Indirect edges to a transitive dependency** require positive allowlist - evidence: either a literal root naming the dependency under the crate root - it defines (`[lib] name = "..."` when set, otherwise its package name), or - a wildcard root that may expand to it. A `package = "..."` alias cannot apply - here -- only a crate that *declares* a dependency can rename it, and an - indirect dependent declares no edge to the target at all. This exists - because `cargo-check-external-types` attributes a - re-exported type to the crate that *defines* it: `fetch_azure` - allowlists `typespec_client_core::*` for a trait `azure_core` - re-exports, while depending only on `azure_core`. Such an edge is - invisible to a direct-dependency scan. - - Here absent or malformed metadata is *not* read as exposure. Failing - closed on an indirect edge would match every transitive dependency of - every crate that declares no allowlist, forcing unrelated crates to - breaking. Nothing is missed: a crate with no allowlist that really does - expose the type still fails closed on its direct edge to whichever - intermediate carries it, and the fixpoint propagates that upward. - -The policy must remain validated by `cargo-check-external-types`: an -extra allowlist entry can cause an unnecessary breaking bump, while -omitting an actually exposed type is a policy-validation failure. - -**How the change type is determined.** `cargo semver-checks` is invoked -as a CLI (not as a library) and its textual result is parsed into one of -our change types. The mapping mirrors the tool's own -[`required_bump`](https://docs.rs/cargo-semver-checks/latest/cargo_semver_checks/struct.CrateReport.html#method.required_bump) -notion (major / minor / none); the exact parsing lives in -`ConvertFrom-SemverChecksOutput` (`scripts/lib/releasing.ps1`): - -| `cargo semver-checks` result | change type | +`deps` contains normalized normal and build dependencies; dev dependencies are +excluded. + +`modified` remains publishable-only for changed-mode selection. +`workspaceModified` also records unpublished workspace changes so proc-macro +review cannot skip a private implementation helper that changed. +`modifiedFiles` records the sorted baseline-diff paths and lets the resolver +reject a first release justified only by tests, benchmarks, or generated files. +The paths come from one frozen published/unpublished workspace scan and are +ordered ordinally. +`manifestDependencyScopes` records whether changed dependency declarations are +normal, build, or dev scoped, and whether package features changed. Selection +validation uses it to prevent dev-only manifest edits from becoming release +seeds. +`manifestOtherChanged` distinguishes a pure dev-dependency edit from another +mixed manifest change without deciding whether that other edit requires a +release; lints and `[package.metadata]` remain ignorable release metadata. +`rustImplementationChanged` is true only when the crate's own packaged Rust +source changed beyond doc comments (a non-comment line in a `.rs` file under +`src/`, a custom `[lib]` path, or `build.rs`, never `tests/`/`benches/`/ +`examples/`). A previously released library with it false and no exposed +breaking external dependency change cannot be classified `breaking` or +`nonbreaking` on its own account -- a re-exported macro contract break or a +dependency bump reaches it only as a resolver-owned cascade. It fails safe: a +missing baseline or an untracked new source file counts as changed. +`docCommentChanged` is true only when a rustdoc-visible doc comment (`///` or +`//!`) changed in a doc-eligible file (not `build.rs`/tests/benches/examples). +It positively identifies a consumer-visible documentation change: with +`rustImplementationChanged` false and no runtime-manifest or exposed breaking +external dependency change, the resolver requires the selection to be +`authored-doc-fix`, while a plain `//` comment or whitespace edit stays eligible +for `internal-only`. + +For ordinary libraries, public exposure is derived from +`package.metadata.cargo_check_external_types.allowed_external_types`. Fact +gathering resolves dependency aliases and custom `[lib] name` crate roots before +matching allowlist entries. It also records a transitively reachable workspace +package in `exposedDeps` when an allowlist positively identifies that defining +crate through a re-export, even when no direct dependency edge exists. + +Exposure handling is conservative: + +- absent metadata fails closed for direct dependencies because working-tree + changes may not have passed CI yet; +- an explicit empty allowlist proves that no direct dependency type is exposed; +- malformed or wildcard direct entries fail closed; +- indirect exposure requires positive allowlist evidence, so absent or malformed + metadata does not mark every transitive dependency as exposed. + +Proc-macro-only packages cannot expose dependency Rust types: rustc restricts +their public surface to proc-macro entry points. They therefore have no +`exposedDeps` and do not set `exposureUnknown`. Public macro re-exports are recorded separately in `macroPublicDeps` only when +a concrete allowlist root identifies the direct proc-macro dependency. +Wildcards are not positive publication evidence. + +`macroImplementationClosure` identifies workspace code that can change a macro's +behavior. `macroRuntimePartners` is inferred by reversing `macroPublicDeps`: a +package that publicly exposes a proc macro is treated as its runtime façade. +`[package.metadata.oxidizer_release].macro_runtime` remains an escape hatch for +generated-runtime relationships without a public façade edge. +If a macro attestation marks generated runtime paths as changed but no partner +was inferred or declared, resolution blocks with `macroRuntimeUnknown`. + +`macroCompileFixtureChanges` inventories the compile fixtures that changed in a +proc macro's review scope: `tests/ui/**` and `tests/compile_fail/**` `.rs` cases +and their `.stderr`/`.stdout` expectations, owned by the macro, its modified +implementation closure, or its modified runtime partners. The cross-package +reach is the point. A fixture proving that a macro now rejects input it used to +accept normally lives in the runtime façade, where it reads as a plain test-only +edit. Each entry records `ownerPackage`, `ownerPublished`, `path`, `kind`, +`status` (`added`/`modified`/`removed`), `expectedResult`, `baselineRev`, and +`scopeRole`, ordered deterministically. + +## External dependency exposure + +A crate's registry dependency requirements ship inside its published manifest, +so consumers resolve against them directly. `cargo semver-checks` compares this +workspace's own rustdoc and cannot see that a public signature now names a type +from a different major version of a third-party crate. `externalDepChanges` +closes that gap mechanically. + +Current requirements come from `cargo metadata`, which resolves +`workspace = true` inheritance, renames, and target-specific tables. Baseline +requirements come from the package manifest and root `[workspace.dependencies]` +at its own `baselineSha` -- read from Git, because `cargo metadata` cannot +inspect a revision without materializing it. Both sides are normalized the way +cargo normalizes a bare version before comparison. Dev dependencies and +workspace members are excluded; workspace members are already covered by +`deps` and the cascade. + +Each entry records `name`, `baselineReq`, `currentReq`, `kinds`, `breaking`, +and `baselineRev`, sorted ordinally by name. `breaking` is decided by the Cargo +compatibility line -- the leading non-zero component span that governs +unification: + +| Transition | `breaking` | |---|---| -| a major-level change is required | `breaking` | -| only a minor-level change is required | `non-breaking` | -| compatible / no update required | `patch` | -| no prior version-bump commit (new crate) | no constraint | - -Cascade dependents are floored at `patch` (they must re-release to pick -up the new dependency version even when their own public API is -unchanged), then raised to the stronger of their own -`cargo semver-checks` result and the exposed-dependency cascade. - -#### Proc-macro-only packages require manual SemVer review - -`cargo semver-checks` deliberately supports ordinary library targets, -not proc-macro-only targets. For a package whose `cargo metadata` -targets contain `proc-macro` but no ordinary `lib` target, the tool exits -with "no crates with library targets selected". This is expected: its -rustdoc-based analysis cannot validate the procedural macro contract, -including exported macro names, accepted input syntax, diagnostics, or -generated output. - -The release tooling detects this target shape from the workspace -metadata **before invoking `cargo semver-checks`**. It does not reinterpret -the unsupported-tool error as success and does not guess a breaking -change: - -- Every proc-macro-only package in the release set is shown in the - standard interactive package dialog, even when it was supplied via - `-Packages` or was cascade-added without changes in its own folder. -- The tool asks the same questions for every package. For a proc macro, it - skips the unsupported automated check and records the answer as a manual - review. -- For a proc macro that is not yet in the plan, choosing **No material - changes** completes the review. The package is not released unless - another package needs it. If that happens later in the same run, the - proc macro gets a patch release without another prompt. -- Use **View diff**, then either keep the currently planned change type - or select breaking / non-breaking / patch. For a targeted package, a - new selection replaces the provisional `-Packages` change type. For a - cascade-added package, it replaces the mechanical `patch` floor. -- The final release plan labels the package as manually classified and - states that `cargo-semver-checks` was not run for it. -- Ordinary library dependents keep their normal behavior: each is - re-released at least as `patch`, and its own public API is still - analysed by `cargo-semver-checks`. A manually chosen proc-macro - severity is never copied to dependents. -- If the proc-macro release is breaking, the tool asks the maintainer to - review each published crate that directly depends on it. -- If one of those crates is also breaking, the tool then reviews that - crate's direct dependents. Otherwise, the extra review stops there. - Each crate keeps its own result; the proc macro's result is never copied - to another crate. For `0.0.x` packages, every release is breaking, so - the review continues to the next set of direct dependents. - -The CI SemVer report follows the same target detection. It skips the -unsupported invocation, emits a `warn` row saying manual proc-macro -review is required, and does **not** claim that the version increment was -automatically verified. For a breaking proc-macro increment, CI marks -the direct published consumer for manual review while retaining that -consumer's ordinary `cargo-semver-checks` result. It continues only -through consumers whose own version increment is breaking. If a required -direct consumer is absent from the publishing set, the report calls out -the incomplete review chain. If CI cannot determine a reviewed package's -baseline, it conservatively continues the warning to the next edge rather -than treating the unknown result as non-breaking. - -Build and test validation are separate from SemVer validation. The -release driver runs `cargo check --workspace` after applying the plan, -and normal CI exercises the workspace tests. Those checks can catch -compilation failures and tested behavioral regressions, but passing them -does not prove compatibility for exported macro names, all accepted -inputs, diagnostics, or generated code. Review those aspects explicitly. - -For example, to validate the main consumer and release -`templated_uri_macros`, run: +| `^2.0.111` to `^2.9.0` | false | +| `^0.5.1` to `^0.5.9` | false | +| dependency added | false | +| `^2.0.111` to `^3.0.2` | true | +| `^0.5.1` to `^0.6.0` | true | +| dependency removed | true | +| either side unreadable (`*`, comparator ranges, conflicting terms) | true | + +Because a requirement can only change through an edited manifest, a package +whose sole change is an inherited `[workspace.dependencies]` bump is promoted to +`modified` and `workspaceModified`, and the affected scope is added to +`manifestDependencyScopes`. `cargo publish` inlines the inherited value, so its +published manifest genuinely changed even though nothing under +`crates//` was touched. + +`externalExposedDeps` applies the `cargo_check_external_types` allowlist to the +package's current external dependencies, with the same fail-closed rules used +for `exposedDeps`: absent or malformed metadata exposes every external +dependency, an explicit empty allowlist exposes none. Proc-macro-only packages +always report an empty list -- a proc macro exports behavior, and rustc keeps +foreign type identity from crossing the macro boundary. Their dependency +upgrades are judged by the macro contract instead. + +The resolver imposes a floor when a breaking change names a dependency in +`externalExposedDeps`: the classification must be `breaking` +(`externalExposureUnderclassified` otherwise) and the selection reason must be +`breaking`, including for a decline (`externalExposureUnderselected` otherwise). +First-ever releases are exempt, having no prior requirement to invalidate. + +Facts use `schemaVersion: 5`. The resolver rejects older or incomplete facts +instead of silently disabling macro-contract checks; regenerate facts after +updating the release tooling. + +## Classification + +For every previously released ordinary library that may enter the release set: + +```text +cargo semver-checks --package --baseline-rev \ + --all-features --color never +``` + +The baseline is the most recent reachable commit that changed the package's +declared version. It is rebuilt from repository history, so no registry access is +required. + +Map detected compatibility requirements to `breaking`, `nonbreaking`, or +`patch`. Tool and build failures are fatal. `cargo semver-checks` proves +compatibility but does not catch every public signature/type change and may not +identify a new public API as requiring a minor bump. Source-diff review must +elevate missed incompatibilities to `breaking` and backward-compatible additions +to `nonbreaking`. In particular, review manual auto-trait implementations and +their generic bounds: replacing structural derivation can remove implementations +for previously accepted type arguments without being reported by the tool. +Likewise, a major dependency upgrade is breaking when that dependency appears in +the crate's exposed public types, even if the crate-local Rust source is +unchanged. + +Packaged documentation repairs that fix broken links or incorrect consumer +guidance are patch changes. Opaque generated README metadata and dependency-link +version refreshes do not independently seed a release when they are only +byproducts of another package's planned release. + +First releases do not run against their introducing commit. They publish at the +version already declared in `Cargo.toml`, unless explicitly pinned higher. + +Proc-macro-only packages require a `macroContracts` attestation covering: + +- exported macro names and derive helper attributes; +- accepted syntax and compile success/failure; +- generated behavior, public API, bounds, and implementations; +- generated runtime paths and requirements; +- hygiene and name resolution. + +The verdict is `compatible`, `nonbreaking`, or `breaking`. It includes reviewed +packages, channel decisions, and concrete evidence. Diagnostic wording and token +formatting are patch unless they alter documented behavior. `manualReview` +remains true. + +For changed/all selection, the request records an evidenced accept/decline +decision for every candidate under its canonical folder key. Generated crate READMEs and changelogs, tests, +benchmarks, dev dependencies, and release-only metadata do not seed releases. +Normal/build dependency declaration or feature changes do seed a patch because +they change the published manifest. Authored Rust docs may seed a patch. +Selection considers only the package's own diff; cascades are resolver-owned. +An all-declined decision set is still resolved and emitted as an empty plan. + +A `behavior-fix` accept must be measured, not narrated. Its decision carries +`regressionEvidence`: one or more `consumer-runtime`, `consumer-compile`, or +`packaged-artifact` probes, each recorded at the release baseline and at the +current revision with a `pass`/`fail` `result`, the `revision` measured, and the +process `exitCode`. Only a baseline failure that now passes demonstrates the +fix. No probe, or a probe whose outcome did not improve, blocks with +`behaviorFixUndemonstrated`; a measurement that is incomplete, contradicts its +own exit code, or compares one revision against itself blocks with +`behaviorEvidenceInconclusive`. Both produce an empty release plan, so an +internal adaptation that preserves observable behavior cannot seed a release — +it is `internal-only`. Other selection reasons are unaffected. + +Proc-macro compile compatibility is measured with the same consumer fixture +against baseline and current packages. A baseline pass that becomes a current +failure is breaking; parser acceptance without end-to-end evidence is not a +separate contract. + +Those measurements are not free text. Every fixture in +`macroCompileFixtureChanges` must appear in the contract's `compileEvidence` +with a structured `baseline` and `current` (`result` of `pass`/`fail`, the +`revision` measured, and the compiler `exitCode`); a `.stderr`/`.stdout` +obligation is discharged by measuring its `.rs` sibling. The resolver derives a +verdict floor from those outcomes — pass→fail is breaking, fail→pass is +nonbreaking, an unchanged outcome is compatible — and blocks a declared verdict +below the floor with `macroVerdictUnderclassified`. Unmeasured fixtures block +with `macroCompileFixtureUnevidenced` and unusable measurements with +`macroCompileEvidenceInconclusive`, each producing an empty release plan. A +derived break additionally forbids declining the package or justifying it with a +weaker selection reason. Fixtures owned by a published implementation dependency +must still be measured but do not set the macro's floor, because that crate +carries its own independent classification. + +A breaking external dependency requirement change on a dependency in +`externalExposedDeps` imposes the same kind of floor without any recorded +evidence to weigh: the classification and the selection reason must both be +`breaking`. Private external dependencies and proc-macro-only packages are +untouched by this rule. + +Compatibility classification follows implemented API and verified consumer +behavior, not TODO/design claims. A passing SemVer check plus an +`impl Trait`-to-concrete return refinement remains nonbreaking when the concrete +type implements the same trait and consumer probes show no regression. +The concrete type must also preserve prior auto-trait and lifetime-capture +guarantees. + +## Cascade rules + +Every released dependency gives each previously released, publishable direct +dependent a patch floor so it can pick up the new dependency requirement. +Never-published dependents are not cascade releases. + +A package receives an ordinary Rust type breaking floor when: + +1. the dependency's actual version transition is breaking under Cargo + compatibility; and +2. the package exposes that dependency through `exposedDeps`, or is a direct + dependent with `exposureUnknown = true`. + +The exposure edge may be direct or may identify a transitively reachable +defining crate through a public re-export. Indirect packages receive no patch +floor for compatible dependency changes because they do not declare the +dependency requirement themselves. + +This is a fixed-point calculation. A strengthened package can strengthen its own +dependents, including through chains and diamonds. Every `0.0.z` bump therefore +propagates as breaking across ordinary type-exposure edges even when its source +classification was patch. + +The release set is ordered dependency before dependent. Duplicate normal/build +edges are deduplicated. Unpublished packages are excluded. + +Proc-macro cascades are separate: + +- an implementation dependency release gives the proc macro a patch floor; +- its Cargo-incompatible version does not imply a broken macro contract; +- a required but missing macro review blocks resolution instead of guessing; +- a reviewed breaking macro contract propagates only through + `macroPublicDeps`; +- a private proc-macro dependency remains a patch pickup; +- generated-runtime relationships require review when the runtime breaks. + +The plan records `contractBreaking`, edge class, judgment, and judgment source +so macro decisions remain auditable. + +## Pins and force + +A proc-macro exact pin still requires a contract attestation because version +arithmetic cannot prove behavioral compatibility. A pin below its objective or +cascade requirement is rejected. With `force: true`, +the resolver retains the exact pin, emits a warning, and preserves the stronger +effective change type for downstream cascade decisions. + +Force never permits a downgrade or a pin equal to the current version. + +## Consensus + +Before applying a plan, freeze facts, classifications, evidence, request JSON, +and resolver output. At least two additional model families review the +classifications and verify that the resolver output follows from them. + +The resolver is authoritative for arithmetic, cascades, pins, and ordering. +Models do not independently replace it with hand-computed plans. Any +classification or rule disagreement stops the release instead of being averaged +or silently resolved. + +## Atomic application + +Apply a resolved plan with: ```powershell -cargo test -p templated_uri -./scripts/release-packages.ps1 -Packages 'templated_uri_macros@patch' +./.github/skills/release-packages/scripts/apply-plan.ps1 -PlanPath plan.json ``` -The `patch` token is the provisional plan entry, not an automated -compatibility verdict. In the standard package dialog, view the diff -and choose the actual change type before allowing the release to proceed. -If that choice is breaking, the planner next requires review of -`templated_uri`, its direct published consumer; keep or elevate -`templated_uri` based on whether its public contract exposes the macro -change. - -**Baseline semantics.** The baseline is the crate's previous -version-bump commit — the most recent commit (before the change under -review) that altered the crate's `[package] version`. Because it comes -from git history rather than a registry, a version that was committed but -never published *is* the baseline: an aborted release that bumped -`bytesbuf` to `4.0.0` without publishing means the next change is -compared against `4.0.0`, not a stale published `3.3.3`. A brand-new -crate with no prior version-bump commit has no baseline and imposes no -constraint. This works offline and in enterprise environments with no -crates.io access, since the baseline API is rebuilt from the crate's own -source at the baseline commit. - -The planner enforces **topological consistency**: if a user-supplied -change type for a package is *weaker* than `cargo semver-checks` -requires (for that package or via a cascade), the planner auto-upgrades -it and notes the upgrade in the review output. The caller's `-Packages` -token is therefore a *lower bound*, not a guarantee — the caller can -always elevate further on the next iteration of the review, but cannot -suppress a change type the API analysis requires. - -### Errors the planner rejects - -- An explicit semver that is not strictly greater than the package's - current on-disk version. (Always fatal — `-Force` does not relax this.) -- A user-supplied change type that pins the package *below* what - `cargo semver-checks` (or the cascade) computes for it. (The planner - can auto-upgrade ordinary change-type tokens, but treats an explicit - semver token as a hard pin — if the explicit version is below what the - analysis requires the planner errors instead of silently overriding - the caller. Pass `-Force` to override: the pin is honored verbatim, the - package's effective change-type tag is still upgraded to record the - stronger unmet requirement, and a warning is printed flagging that - consumers may break. Exposure propagation continues past a forced pin: - the pin lowers the version number, not the incompatibility, so dependents - that expose the crate still inherit the break.) - ---- - -## Cascade Organisation Invariants - -The dependency-scan loop (which surfaces modified-but-unreleased -workspace packages for the caller to review) operates on two invariants. - -### Invariant A — A cascade-added release-set member is never itself surfaced as a finding in the dep-scan. - -A package that received only a cascade-applied version-number change -(no pre-existing developer modifications) requires no caller review — -its version-number increment is mechanical and follows directly from -the released dependency. Such packages must not appear in the dep-scan -prompt. - -Cascading toward dependents *does* enlarge the release set, which -enlarges the set of packages whose dependencies the dep-scan walks. So -a cascade can INDIRECTLY cause pre-existing modified packages — packages -that already had unreleased modifications, not the cascade-added -members themselves — to surface as new findings on a subsequent -iteration of the review loop. This is the desired behaviour: the -caller wants to know about every package whose modifications might -need a release, and the cascade just enlarged the relevant scope. - -The implementation upholds the no-self-surface part of this invariant -by snapshotting the "has unreleased modifications" set BEFORE any -cascade runs, so the snapshot reflects pre-cascade reality. - -### Invariant B — User-source releases are never surfaced; cascade-source releases are surfaced only when not already at `breaking`. - -The dep-scan surfaces a release-set member only when both: - -1. It is a cascade-source release (added by the cascade toward - dependents, not by a `-Packages` token or a caller acceptance - during a prior dep-scan iteration). -2. Its cascade-applied change type is not yet `breaking` (so there is - room for the caller to elevate it). - -A user-source release is the caller's final decision. It is never -re-prompted, regardless of its change type — the caller already chose -the change type, and to revise it they re-invoke the script with a -different `-Packages` token (after first reverting the on-disk state). -A cascade-source release already at `breaking` is also dropped: no -higher change type exists, so there is nothing for the caller to -elevate. - -The user-review queue therefore contains two categories of finding: - -- **Modifications not part of this release** — packages with - modifications that are NOT in the release set. The caller must decide - whether the modifications warrant a release. -- **Elevation candidates** — packages with modifications that ARE in - the release set as cascade-source releases but whose cascade-applied - change type is not yet `breaking`. The caller must decide whether to - elevate. - ---- - -## How to release one or more packages - -1. Decide which packages you want to release and the change type for each. - This is the caller's judgment. There is no algorithmic "correct" - answer — review the cumulative diff being released (source + dependency - edits) and decide whether each package's change is breaking, - backward-compatible, a pure internal patch, or whether to pin to an - explicit version. Picking too weak a change type causes consumers - to silently get incompatible behaviour after `cargo update`; - picking too strong a change type is harmless except it forces direct - dependents to bump as well. - -2. Run one of: - - ```powershell - # Targeted — pin the plan up front: - ./scripts/release-packages.ps1 -Packages 'pkg1@','pkg2@' - - # Guided walk through every package with on-disk modifications: - ./scripts/release-packages.ps1 -Changed - - # Guided walk through every publishable package, modified or not: - ./scripts/release-packages.ps1 -All - ``` - - The script will: - - Parse the tokens (targeted mode) or seed the review loop with every - modified / every publishable package (guided modes) and compute the - resolved release set, including the cascade toward dependents. - - Show the release plan. - - For each workspace package with unreleased modifications that is - transitively pulled in by something in the release set (and is not - itself in the release set with a cascade-applied change type of - `breaking`), show a per-package menu where you can view the diff and - decide whether to include the package, elevate its change type, or - leave it out. In `-All` mode the same menu is also shown for - publishable packages with no on-disk changes; the "View diff" option - is relabelled `View diff (no changes in this package)` so the empty - state is obvious before you open the editor. - - For every proc-macro-only package in the release set, show the same - menu as a mandatory manual SemVer review. This includes targeted - packages and unchanged proc-macro dependents added by cascade. - A breaking result then surfaces direct published consumers one edge - at a time; propagation stops at the first consumer reviewed below - breaking. - - After review, apply all version-number increments, changelog - updates, README regeneration, `Cargo.toml` rewrites, and workspace - `[workspace.dependencies]` updates in one shot. - -3. Commit the resulting changes and open a PR. - -Once your PR is merged, automation tags the commit and pushes each -released crate to crates.io. - -### Re-running on the same branch - -You may run `release-packages.ps1` multiple times on the same branch, -but each invocation reads the *on-disk* version of every package (the -planner uses the version it finds in `Cargo.toml`, not the version in -any base ref). A second run therefore plans increments *on top of* -whatever the first run already wrote — typically not what you want -when re-planning the same release. - -If a previous run produced changes you want to discard before -re-planning, use `git reset` / `git restore` to revert the on-disk -state first, then re-run with the corrected arguments. - ---- - -## Guided modes (`-Changed`, `-All`) - -Both `-Changed` and `-All` walk the workspace one package at a time and -prompt for a per-package release decision. They differ only in which -packages get surfaced: - -- `-Changed` surfaces every published workspace package with unreleased - modifications. Use this when you know "something changed and probably - needs releasing" but do not yet have the full `-Packages` list ready. - If the scan finds no packages with unreleased modifications, the - script prints a confirmation and exits without prompting. - - The change scan only inspects files under `crates//`. Edits - to anything outside a package directory — the workspace-level - `Cargo.toml`, `.cargo/`, `deny.toml`, shared CI workflows, top-level - scripts — are invisible to the scan even if they affect how the - package builds or behaves. Switch to `-All` (or pass the affected - packages with `-Packages`) when a cross-cutting change matters. - -- `-All` surfaces every published workspace package, regardless of - whether the on-disk content has been modified. Use this when you want - to force-walk the entire workspace — for example to coordinate a - multi-package release after an internal refactor, or when you suspect - the change scan might be missing something. Packages with no detected - changes still expose the View-diff option (relabelled to make the - empty state obvious) so muscle-memory navigation continues to work. - -For each surfaced package the menu lets you: - -- **View the diff** since the last release commit. -- **Ignore** the package (leave it unreleased; treat the change as - immaterial or not yet ready). -- **Release as breaking / non-breaking / patch** — synthesises a release - token for the package internally and feeds it back into the planner. - -Acceptances behave exactly as if you had passed the corresponding -`-Packages` token: the planner re-resolves the release set, computes -the cascade toward dependents, and the next iteration surfaces any -newly-relevant elevation candidates. Decisions are final — each -package is prompted at most once. If a later acceptance cascade-pulls -a previously-ignored package into the release set, or strengthens an -already-reviewed package's cascade level, the planner silently accepts -the cascade-applied level (reflecting the user's earlier decision not -to elevate). The final release plan summary records the cascade -reasons for every released package. - -Conceptually, both guided modes are equivalent to imagining a virtual -`*` package that depends on every surfaced workspace package and -running the planner to cascade releases from `*` outward. There is no -real `*` token; the review loop seeds its dependency BFS with every -surfaced package as an additional root, so per-package chains between -surfaced packages emerge naturally during planning. - -For each surfaced package the menu lists **every in-workspace dependency -chain** ending at that package — not only the chains rooted at the -current release set. This gives the reviewer a release-set-independent -big-picture view of what releasing the package could ripple through -(cascading may pull more dependents into the release set after the -prompt, so a release-set-rooted listing would be misleadingly narrow). -A package with no in-workspace dependents is shown with the hint -"No in-workspace dependents". - -If you skip every prompt, the script exits without writing any files. - ---- - -## Why we say "package" everywhere - -Cargo's official term for a workspace member is "package", so the -release tooling uses "package" throughout the PowerShell API surface -(`-Packages`, `-PackageName`, etc.) and in all human-readable output. - -The token "crate" survives only in identifiers carried over from -Cargo's own vocabulary — the filesystem directory `crates/`, -`[workspace.dependencies]`, `Cargo.toml`, `cargo metadata`, `crates.io`. +The helper: + +1. verifies each package and workspace dependency is still at the plan's `from` + version; +2. edits only `[package].version` and the existing workspace dependency inline + table's `version` value; +3. generates changelogs in dependency-first order; +4. runs `just readme` once; +5. runs Cargo metadata and workspace checks; +6. verifies every applied package and workspace dependency version. + +It snapshots package manifests, the root manifest and lockfile, changelogs, and +all possible crate README paths. Any failure restores modified files and removes +files created by the failed release. + +Do not publish packages unless publication was explicitly requested. + +## CI SemVer report + +`.github/workflows/main.yml` invokes `scripts/ci/semver-report.ps1` for package +version changes in a PR. It uses the same release-baseline and proc-macro +semantics but remains outside the skill because it is a CI entry point. + +The report verifies whether versions already written in the PR are sufficient. +It does not replace release planning or source-diff review. + +## Tests + +Mechanical behavior is covered by the Pester suite: + +- stable, `0.x`, and `0.0.x` arithmetic; +- patch, additive, and breaking synthetic Cargo changes; +- linear, diamond, duplicate-edge, renamed-root, direct, indirect, and + unknown-exposure graphs; +- first releases, unpublished packages, proc-macros, pins, and force; +- changelog rendering; +- successful application and rollback after validation failure. + +Run: + +```powershell +pwsh -NoProfile -File scripts/tests/Pester/Run-Tests.ps1 +``` diff --git a/scripts/lib/changelog.ps1 b/scripts/lib/changelog.ps1 new file mode 100644 index 000000000..6f7e91d40 --- /dev/null +++ b/scripts/lib/changelog.ps1 @@ -0,0 +1,365 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Changelog generation for the release tooling. + +.DESCRIPTION + Deterministic CHANGELOG.md generation reused by the release skill's small + changelog helper. Extracted + from the retired interactive release driver so the only release logic that + remains in scripts/ is the mechanical, format-heavy work the prompt should not + re-derive by hand: grouping conventional commits into sections, rendering PR + links, folding `## Unreleased`, and emitting cascade "Now requires X of Y" + bullets. All planning/cascade/version logic now lives in the release prompt. + + Depends on scripts/lib/releasing.ps1 for Invoke-Git, Get-FileLineEnding and the + conventional-commit / PR-reference regexes. +#> + +. "$PSScriptRoot/releasing.ps1" + +# Maps commit types (e.g., 'chore') to a common group key (e.g., 'task'). +$script:TypeGroupMapping = @{ + 'chore' = 'task'; + 'doc' = 'docs'; + 'misc' = 'miscellaneous'; +} + +# Maps the final group key to a user-friendly header in the changelog. +$script:HeaderNameMapping = @{ + 'breaking' = '⚠️ Breaking'; + 'build' = '🏗️ Build System'; + 'ci' = '🔄 Continuous Integration'; + 'docs' = '📚 Documentation'; + 'feat' = '✨ Features'; + 'fix' = '🐛 Bug Fixes'; + 'miscellaneous' = '🧩 Miscellaneous'; + 'perf' = '⚡ Performance'; + 'refactor' = '♻️ Code Refactoring'; + 'style' = '🎨 Styling'; + 'task' = '✔️ Tasks'; +} + +# Defines the preferred order for commit type sections in the changelog. +$script:TypeOrder = @('breaking', 'feat', 'fix', 'perf', 'docs', 'task', 'refactor', 'build', 'ci', 'style') + +# Defines commit types that should be excluded from the changelog. +$script:IgnoredTypes = @('test') + +function Sort-KeysByPreferredOrder { + param( + [string[]]$allKeys, + [string[]]$preferredOrder + ) + $sortedKeys = [System.Collections.ArrayList]::new() + $remainingKeys = [System.Collections.ArrayList]::new() + $remainingKeys.AddRange($allKeys) + + foreach ($key in $preferredOrder) { + if ($remainingKeys.Contains($key)) { + $null = $sortedKeys.Add($key) + $null = $remainingKeys.Remove($key) + } + } + + $remainingKeys.Sort() + $sortedKeys.AddRange($remainingKeys) + return $sortedKeys +} + +function Format-ConventionalCommits { + param( + [string[]]$rawCommitMessages, + [string]$prBaseUrl + ) + + if (-not $rawCommitMessages) { + return @() + } + + $groupedCommits = [ordered]@{} + + foreach ($message in $rawCommitMessages) { + $type = "miscellaneous" + $description = $message + $isConventional = $false + + $conventionalMatch = $script:ConventionalCommitRegex.Match($message) + $isBreaking = $false + if ($conventionalMatch.Success) { + $type = $conventionalMatch.Groups[1].Value + $isBreaking = $conventionalMatch.Groups[2].Value -eq '!' + $description = $conventionalMatch.Groups[3].Value + $isConventional = $true + } + + if ($isConventional -and $script:IgnoredTypes -contains $type) { + continue + } + + if (-not [string]::IsNullOrEmpty($prBaseUrl)) { + $prMatch = $script:PrReferenceRegex.Match($description) + if ($prMatch.Success) { + $fullMatch = $prMatch.Groups[0].Value + $prNumber = $prMatch.Groups[2].Value + $prLink = " ([#$prNumber]($prBaseUrl/$prNumber))" + $description = $description.Substring(0, $description.Length - $fullMatch.Length) + $prLink + } + } + + # Breaking changes are grouped separately, regardless of the commit type + $groupKey = if ($isBreaking) { + 'breaking' + } elseif ($script:TypeGroupMapping.ContainsKey($type)) { + $script:TypeGroupMapping[$type] + } else { + $type + } + + if (-not $groupedCommits.Contains($groupKey)) { + $groupedCommits[$groupKey] = [System.Collections.ArrayList]::new() + } + + [void]$groupedCommits[$groupKey].Add(" - $description") + } + + $sortedKeys = Sort-KeysByPreferredOrder -allKeys $groupedCommits.Keys -preferredOrder $script:TypeOrder + $formattedLines = @() + foreach ($type in $sortedKeys) { + if ($groupedCommits[$type].Count -gt 0) { + $headerName = if ($script:HeaderNameMapping.ContainsKey($type)) { $script:HeaderNameMapping[$type] } else { $type.Substring(0, 1).ToUpper() + $type.Substring(1) } + $formattedLines += @("- $headerName", "") + @($groupedCommits[$type]) + @("") + } + } + + if ($formattedLines.Count -gt 0 -and [string]::IsNullOrWhiteSpace($formattedLines[-1])) { + if ($formattedLines.Count -gt 1) { + $formattedLines = $formattedLines[0..($formattedLines.Count - 2)] + } else { + $formattedLines = @() + } + } + + return $formattedLines +} + +function Extract-UnreleasedSection { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content + ) + + if ([string]::IsNullOrEmpty($Content)) { + return $null + } + + # (?ims) — Multiline (^ matches line starts) + Singleline (. matches + # newlines, so the non-greedy body can span lines) + IgnoreCase. + $pattern = '(?ims)^##[ \t]+(?:\[Unreleased\]|Unreleased)[ \t]*\r?\n(?.*?)(?=^##[ \t]|\z)' + $match = [regex]::Match($Content, $pattern) + if (-not $match.Success) { + return $null + } + + $body = $match.Groups['body'].Value + $lines = @($body -split "`r?`n") + + # Strip trailing blank lines. + while ($lines.Count -gt 0 -and [string]::IsNullOrWhiteSpace($lines[-1])) { + $lines = if ($lines.Count -eq 1) { @() } else { @($lines[0..($lines.Count - 2)]) } + } + # Strip leading blank lines. + while ($lines.Count -gt 0 -and [string]::IsNullOrWhiteSpace($lines[0])) { + $lines = if ($lines.Count -eq 1) { @() } else { @($lines[1..($lines.Count - 1)]) } + } + + return [pscustomobject]@{ + BodyLines = [string[]]$lines + ContentWithoutSection = $Content.Remove($match.Index, $match.Length) + } +} + +function Write-Changelog { + param( + [string]$packageName, + [string]$newVersion, + [string]$packageFolder, + [string]$changelogFile, + [string]$prBaseUrl, + # Optional: when this package is being released as a cascade-from-dependency, + # describe one or more cascades so a maintenance/breaking entry can be + # written even if the package has no commits since its last release. Each + # element shape: @{ Target = ''; Version = ''; Breaking = $false }. + # The section header is `⚠️ Breaking` if ANY reason is Breaking, otherwise + # `🔧 Maintenance`; one bullet is emitted per reason in deterministic + # (Target-sorted) order. Element shape is duck-typed (.Target / .Version / + # .Breaking) so both hashtables and [pscustomobject] are accepted. + [object[]]$cascadeReasons = $null + ) + + $hasCascade = ($null -ne $cascadeReasons) -and ($cascadeReasons.Count -gt 0) + + # Read the existing changelog up front and extract any `## Unreleased` + # section. The body of that section will be folded into the new version + # section we're about to create — leaving it behind would orphan + # manually-curated release notes below the freshly-inserted version + # heading. Unreleased presence alone is enough reason to write a new + # section, so we check it in the no-content guard below. + $existingContent = $null + $existingHadContent = $false + $unreleasedLines = @() + if (Test-Path $changelogFile) { + $existingContent = Get-Content $changelogFile -Raw + if ($existingContent) { + $existingHadContent = $true + $extracted = Extract-UnreleasedSection -Content $existingContent + if ($null -ne $extracted) { + $unreleasedLines = $extracted.BodyLines + $existingContent = $extracted.ContentWithoutSection + } + } + } + + $hasUnreleased = $unreleasedLines.Count -gt 0 + + $tags = Invoke-Git -Arguments @('tag', '--list', "$packageName-v*") + $latestTag = $null + if ($null -eq $tags -or $tags.Count -eq 0) { + Write-Warning "No tags found for package '$packageName'. Generating changelog from all history." + } else { + $filteredTags = @($tags | Where-Object { $_ -match "^${packageName}-v\d+\.\d+\.\d+$" }) + if ($filteredTags.Count -gt 0) { + $sortedTags = @($filteredTags | Sort-Object { [version]($_ -replace "${packageName}-v", '') }) + $latestTag = $sortedTags[-1] + } else { + Write-Warning "No valid semantic version tags found for package '$packageName'. Generating changelog from all history." + } + } + + $currentDate = (Get-Date).ToString('yyyy-MM-dd') + + # Get commits since the latest tag (unreleased commits) + $range = if ($latestTag) { "$latestTag..HEAD" } else { "HEAD" } + $rawCommits = Invoke-Git -Arguments @('log', $range, '--pretty=format:%s', '--', $packageFolder) + if ($null -eq $rawCommits -or $rawCommits.Count -eq 0) { + $rawCommits = @() + } else { + $rawCommits = @($rawCommits) + } + + $formattedCommits = @() + if ($rawCommits.Count -gt 0) { + $formattedCommits = Format-ConventionalCommits -rawCommitMessages $rawCommits -prBaseUrl $prBaseUrl + } + + if ($formattedCommits.Count -eq 0 -and -not $hasCascade -and -not $hasUnreleased) { + if ($rawCommits.Count -eq 0) { + Write-Warning "No unreleased commits found to add to the changelog." + } else { + $filteredCount = $rawCommits.Count + $noun = if ($filteredCount -eq 1) { 'commit was' } else { 'commits were' } + Write-Warning "No relevant commits found to add to the changelog (all $filteredCount $noun filtered out)." + } + return + } + + # Prepend cascade entries when this package is being released because one + # (or more) of its dependencies was released. Emits structured + # "Now requires of " bullets — deliberately formal + # rather than colloquial — under the appropriate section: + # - 🔧 Maintenance (when no contributing cascade is breaking) + # - ⚠️ Breaking (when at least one contributing cascade is breaking) + # Bullets are sorted by Target name for deterministic output across runs. + # If the same section header was already produced by + # Format-ConventionalCommits for this release, the cascade bullets are + # merged into that existing section instead of creating a duplicate header. + if ($hasCascade) { + $anyBreaking = $false + foreach ($r in $cascadeReasons) { + if ([bool]$r.Breaking) { $anyBreaking = $true; break } + } + $sectionHeader = if ($anyBreaking) { '- ⚠️ Breaking' } else { '- 🔧 Maintenance' } + + $sortedReasons = @($cascadeReasons | Sort-Object -Property @{ Expression = { $_.Target } }) + $cascadeBullets = @($sortedReasons | ForEach-Object { + " - Now requires ``$($_.Version)`` of ``$($_.Target)``" + }) + + $existingHeaderIdx = -1 + for ($i = 0; $i -lt $formattedCommits.Count; $i++) { + if ($formattedCommits[$i] -eq $sectionHeader) { + $existingHeaderIdx = $i + break + } + } + + if ($existingHeaderIdx -ge 0) { + # Find the end of this section (next top-level "- " header or end of list). + $insertIdx = $formattedCommits.Count + for ($i = $existingHeaderIdx + 1; $i -lt $formattedCommits.Count; $i++) { + if ($formattedCommits[$i] -match '^- \S') { $insertIdx = $i; break } + } + # Trim trailing blank lines belonging to the section. + while ($insertIdx -gt $existingHeaderIdx + 1 -and [string]::IsNullOrWhiteSpace($formattedCommits[$insertIdx - 1])) { + $insertIdx-- + } + $before = if ($insertIdx -gt 0) { @($formattedCommits[0..($insertIdx - 1)]) } else { @() } + $after = if ($insertIdx -lt $formattedCommits.Count) { @($formattedCommits[$insertIdx..($formattedCommits.Count - 1)]) } else { @() } + $formattedCommits = $before + $cascadeBullets + $after + } else { + $cascadeLines = @($sectionHeader, "") + $cascadeBullets + if ($formattedCommits.Count -gt 0) { + $formattedCommits = $cascadeLines + @("") + $formattedCommits + } else { + $formattedCommits = $cascadeLines + } + } + } + + # Build the new version section. User-curated `## Unreleased` body lines + # (if any) lead the section so the manually-authored narrative appears + # first; cascade bullets + commit-derived bullets follow as supplementary + # detail. A blank line separates the two groups when both are present. + $newVersionSection = @("## [$newVersion] - $currentDate", "") + if ($hasUnreleased) { + $newVersionSection += $unreleasedLines + if ($formattedCommits.Count -gt 0) { + $newVersionSection += "" + } + } + $newVersionSection += $formattedCommits + $newVersionSection += "" + + # Insert the new version section into the existing changelog, using the + # Unreleased-stripped content as the base (so the orphaned `## Unreleased` + # heading is no longer present in the output). + if ($existingHadContent) { + # Find the position after "# Changelog" header and any blank lines + # Insert the new version section there + $headerPattern = '^# Changelog\s*\r?\n(\r?\n)*' + if ($existingContent -match $headerPattern) { + # Match the existing file's line-ending convention so we don't introduce + # mixed endings (e.g. CRLF body + LF for the new section). + $eol = Get-FileLineEnding -Path $changelogFile + $headerMatch = [regex]::Match($existingContent, $headerPattern) + $insertPosition = $headerMatch.Index + $headerMatch.Length + $newContent = $existingContent.Substring(0, $insertPosition) + + ($newVersionSection -join $eol) + $eol + + $existingContent.Substring($insertPosition) + Set-Content -LiteralPath $changelogFile -Value $newContent -NoNewline -Encoding utf8 + Write-Host "✅ Changelog updated at '$changelogFile'." + return + } + } + + # If no existing changelog or couldn't parse it, create a new one. + # No existing file to sample from, so default to LF (modern convention; matches + # what .gitattributes normalizes to in repos that enforce it). + $changelogContent = @("# Changelog", "") + $changelogContent += $newVersionSection + Set-Content -LiteralPath $changelogFile -Value (($changelogContent -join "`n") + "`n") -NoNewline -Encoding utf8 + Write-Host "✅ Changelog created at '$changelogFile'." +} diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 deleted file mode 100644 index 0755a2e81..000000000 --- a/scripts/lib/release-flow.ps1 +++ /dev/null @@ -1,2696 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -#Requires -Version 7.0 - -<# -.SYNOPSIS - Release-flow library: helpers and orchestration for scripts/release-packages.ps1. - -.DESCRIPTION - Owns the orchestration helpers, changelog formatters, and the - Invoke-ReleasePackagesMain entrypoint that drives the full package-release - workflow. scripts/release-packages.ps1 is a thin CLI shell that dot-sources - this library and calls Invoke-ReleasePackagesMain. - - This file is NOT an entrypoint. It only defines functions and module-scoped - configuration; dot-source it from another script (or from Pester tests) to - consume its API. - - Depends on scripts/lib/releasing.ps1 (which it dot-sources at the top so - consumers only need to source this file). -#> - -# --- DOT-SOURCE SHARED LIBRARY --- -# -# scripts/lib/releasing.ps1 owns the lower-level reusable building blocks used by -# the release flow below: -# - Compiled regex patterns ($script:ConventionalCommitRegex, $script:PrReferenceRegex, -# $script:SemanticVersionRegex, $script:CargoPackageVersionRegex, $script:GitHubRepoRegex, -# $script:RegexEscapeRegex). -# - Safe git invocation (Invoke-Git) and ref validation (Test-GitRef). -# - SemVer arithmetic (Compare-SemanticVersions, Get-NextVersion, Get-ChangeTypeFromVersions, -# Test-IsBreakingChange) and package-version readers (Get-CurrentVersion, -# Get-PackageVersionFromRef). -# - Workspace metadata (Get-WorkspaceMetadata, Get-WorkspacePackages, -# Invalidate-WorkspaceMetadataCache, Get-AllTransitiveDependents) and -# cargo-semver-checks classification (Invoke-CrateSemverCheck, -# ConvertFrom-SemverChecksOutput, Get-CrateRequiredChangeType, -# Get-StrongerChangeType). -# - Modified-but-unreleased dependency analysis (Get-PackagesWithUnreleasedChanges, -# Get-PackagesWithVersionChanges, Get-UnreleasedModifiedDependencies). -. "$PSScriptRoot/releasing.ps1" - -# --- CONFIGURATION --- - -# Maps commit types (e.g., 'chore') to a common group key (e.g., 'task'). -$script:TypeGroupMapping = @{ - 'chore' = 'task'; - 'doc' = 'docs'; - 'misc' = 'miscellaneous'; -} - -# Maps the final group key to a user-friendly header in the changelog. -$script:HeaderNameMapping = @{ - 'breaking' = '⚠️ Breaking'; - 'build' = '🏗️ Build System'; - 'ci' = '🔄 Continuous Integration'; - 'docs' = '📚 Documentation'; - 'feat' = '✨ Features'; - 'fix' = '🐛 Bug Fixes'; - 'miscellaneous' = '🧩 Miscellaneous'; - 'perf' = '⚡ Performance'; - 'refactor' = '♻️ Code Refactoring'; - 'style' = '🎨 Styling'; - 'task' = '✔️ Tasks'; -} - -# Defines the preferred order for commit type sections in the changelog. -$script:TypeOrder = @('breaking', 'feat', 'fix', 'perf', 'docs', 'task', 'refactor', 'build', 'ci', 'style') - -# Defines commit types that should be excluded from the changelog. -$script:IgnoredTypes = @('test') - -# --- HELPER FUNCTIONS --- - -function Test-CommandExists { - param([string]$Command) - return $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) -} - -function Sort-KeysByPreferredOrder { - param( - [string[]]$allKeys, - [string[]]$preferredOrder - ) - $sortedKeys = [System.Collections.ArrayList]::new() - $remainingKeys = [System.Collections.ArrayList]::new() - $remainingKeys.AddRange($allKeys) - - foreach ($key in $preferredOrder) { - if ($remainingKeys.Contains($key)) { - $null = $sortedKeys.Add($key) - $null = $remainingKeys.Remove($key) - } - } - - $remainingKeys.Sort() - $sortedKeys.AddRange($remainingKeys) - return $sortedKeys -} - -# Parses the -Packages argument array of `release-packages.ps1` into structured -# release request entries. Each token is a string of the form '@', -# where is one of: -# -# - breaking, nonbreaking, patch (case-insensitive change-type keywords) -# - ..[-][+] -# (strict SemVer 2.0 pin — always -# exactly three numeric components, -# no leading zeros; optional -# pre-release identifier and optional -# build metadata. Examples: '1.0.0', -# '2.5.0', '1.0.0-rc.1', -# '0.1.0-beta.2+meta') -# -# Returns an array of pscustomobject entries, one per token: -# -# @{ -# Name = '' # case preserved -# RequestedChangeType = 'breaking'|'non-breaking'|'patch'|$null -# RequestedTargetVersion = '1.2.3'|$null # set only for explicit semver pin -# RawToken = '' -# } -# -# Explicit semver pins (including '1.0.0') are passed through verbatim as -# RequestedTargetVersion; the resolver later rejects pins that are not -# strictly greater than the package's current version. -# -# Validation: -# - The -Packages array must contain at least one token. -# - Each token must contain exactly one '@', neither at the start nor at the -# end. Names are validated via Test-ValidPackageName. Duplicate names -# (case-insensitive) are rejected so the resolver receives a clean unique -# keyset. -# - Whitespace-only tokens are rejected; leading/trailing whitespace around -# a token is trimmed first. -# - Unknown change-type keywords or malformed semvers throw a descriptive -# error that quotes both the token and the offending change-spec text. -# One- or two-component version forms ('1', '1.2') are rejected. -function Parse-ReleaseTokens { - param( - [Parameter(Mandatory = $true)] - [AllowEmptyCollection()] - [AllowEmptyString()] - [AllowNull()] - [string[]]$Tokens - ) - - if ($null -eq $Tokens -or @($Tokens).Count -eq 0) { - throw "No packages to release. Provide at least one '@' token via -Packages." - } - - $seenNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $results = New-Object 'System.Collections.Generic.List[object]' - - foreach ($raw in $Tokens) { - if ($null -eq $raw) { - throw "Encountered a null token in -Packages. Each entry must be a '@' string." - } - $token = $raw.Trim() - if ([string]::IsNullOrEmpty($token)) { - throw "Encountered an empty or whitespace-only token in -Packages. Each entry must be a '@' string." - } - - $firstAt = $token.IndexOf('@') - $lastAt = $token.LastIndexOf('@') - if ($firstAt -lt 1 -or $firstAt -ge ($token.Length - 1) -or $firstAt -ne $lastAt) { - throw "Malformed package token '$raw'. Expected the form '@' with exactly one '@' separating a non-empty package name from a non-empty change specifier (e.g. 'bytesbuf@breaking', 'fetch_hyper@1.2.3', 'http_extensions@1.0.0')." - } - - $name = $token.Substring(0, $firstAt) - $changeSpec = $token.Substring($firstAt + 1) - - if (-not (Test-ValidPackageName -packageName $name)) { - throw "Invalid package name '$name' in token '$raw'. Package names must contain only letters, numbers, hyphens, and underscores; must not start or end with a hyphen or underscore; and must be 64 characters or less." - } - - if (-not $seenNames.Add($name)) { - throw "Duplicate package name '$name' in -Packages list. Each package may appear at most once; release each package with a single combined change type." - } - - $requestedChangeType = $null - $requestedTargetVersion = $null - - switch -CaseSensitive ($changeSpec.ToLowerInvariant()) { - 'breaking' { $requestedChangeType = 'breaking'; break } - 'nonbreaking' { $requestedChangeType = 'non-breaking'; break } - 'patch' { $requestedChangeType = 'patch'; break } - default { - # Strict SemVer 2.0 — three numeric components, optional - # pre-release identifier (-...), optional build metadata - # (+...). 1- or 2-component forms like 'foo@1' or 'foo@1.2' - # are intentionally rejected; pinning a release requires - # full disambiguation. - if ($script:SemanticVersionRegex.IsMatch($changeSpec)) { - $requestedTargetVersion = $changeSpec - } else { - throw "Invalid change specifier '$changeSpec' in token '$raw'. Expected one of: 'breaking', 'nonbreaking', 'patch', or an explicit SemVer 2.0 version with all three components (e.g. '1.0.0', '2.5.0', '1.0.0-rc.1', '1.0.0-beta.2+meta'). One- or two-component forms like '1' or '1.2' are not accepted." - } - } - } - - $results.Add([pscustomobject]@{ - Name = $name - RequestedChangeType = $requestedChangeType - RequestedTargetVersion = $requestedTargetVersion - RawToken = $raw - }) - } - - return $results.ToArray() -} - -# BFS over a workspace baseline to find all published transitive dependents of -# a cargo package (identified by its underscore-normalized cargo name). Mirrors -# Get-AllTransitiveDependents but operates against an in-memory baseline -# snapshot, so it produces deterministic answers even after disk state changes. -# -# Behaviour parity: traverses through unpublished workspace packages (so they -# act as conduits between published packages) but only returns published -# packages in the result list. The target itself is never returned. -function Get-TransitivePublishedDependentsFromBaseline { - param( - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Baseline, - [Parameter(Mandatory = $true)][string]$TargetCargoName - ) - - $toVisit = [System.Collections.Generic.Queue[string]]::new() - $toVisit.Enqueue($TargetCargoName) - $visited = [System.Collections.Generic.HashSet[string]]::new() - [void]$visited.Add($TargetCargoName) - - $dependents = New-Object 'System.Collections.Generic.List[string]' - while ($toVisit.Count -gt 0) { - $current = $toVisit.Dequeue() - foreach ($candidate in $Baseline) { - $candidateNorm = $candidate.Name.Replace('-', '_') - if ($visited.Contains($candidateNorm)) { continue } - if ($candidate.Deps -contains $current) { - [void]$visited.Add($candidateNorm) - $toVisit.Enqueue($candidateNorm) - if ($candidate.Published) { - $dependents.Add($candidate.Folder) - } - } - } - } - - return @($dependents) -} - -# Raises a resolved release-set entry's EffectiveChangeType to at least -# $RequiredChangeType, honouring the same explicit-pin rules the cascade uses: -# * Change-type-only entry: bump EffectiveChangeType + EffectiveTargetVersion, -# flag AutoUpgraded for user-source entries. -# * Pinned entry whose pin still satisfies the requirement: bump the tag, keep -# the pin. -# * Pinned entry whose pin undershoots: throw, unless -Force (then honour the -# pin verbatim, bump the tag, set PinHonoredAgainstCascade, and warn). -# No-op when the entry is already at or above the required change type. -# $RequirementLabel / $RequirementDetail are woven into the throw/warn messages -# so the user can see whether the requirement came from a cascade or from -# cargo-semver-checks analysing the crate's own API. -function Update-EntryForRequiredChangeType { - param( - [Parameter(Mandatory = $true)][pscustomobject]$Entry, - [Parameter(Mandatory = $true)][string]$RequiredChangeType, - [Parameter(Mandatory = $true)][string]$RequirementLabel, - [Parameter(Mandatory = $true)][string]$RequirementDetail, - [switch]$Force - ) - - # Only ever raise the change type — never lower it. Get-StrongerChangeType - # (defined alongside the rank table in releasing.ps1, so it encapsulates the - # ranking rather than reaching across files for $script:ChangeTypeRank) returns - # its first argument on a tie; when the requirement does not exceed the current - # effective type the stronger-of equals the current type and we no-op. - if ((Get-StrongerChangeType $Entry.EffectiveChangeType $RequiredChangeType) -eq $Entry.EffectiveChangeType) { return } - - $requiredVersion = Get-NextVersion -currentVersion $Entry.CurrentVersion -ChangeType $RequiredChangeType - - if (-not [string]::IsNullOrEmpty($Entry.RequestedTargetVersion)) { - # User pinned an explicit version. Verify it numerically satisfies the - # requirement; if not, reject unless -Force honours it verbatim. - $cmpPin = Compare-SemanticVersions -version1 $Entry.RequestedTargetVersion -version2 $requiredVersion - if ($cmpPin -lt 0) { - if ($Force) { - Write-Warning "-Force: honoring explicit pin v$($Entry.RequestedTargetVersion) on '$($Entry.Folder)' even though $RequirementLabel requires at least v$requiredVersion ($RequirementDetail). The package's EffectiveChangeType tag is upgraded to '$RequiredChangeType' but the version on disk will be v$($Entry.RequestedTargetVersion). Consumers may break." - $Entry.EffectiveChangeType = $RequiredChangeType - $Entry.PinHonoredAgainstCascade = $true - } else { - throw "Cannot release '$($Entry.Folder)' as v$($Entry.RequestedTargetVersion): $RequirementLabel requires at least v$requiredVersion because of $RequirementDetail. Specify a higher version pin, use a change-type keyword, or pass -Force to honor the pin verbatim (consumers may break)." - } - } else { - # Pin still satisfies. Bump the tag to record the stronger - # requirement for diagnostics, but keep the pinned version. - $Entry.EffectiveChangeType = $RequiredChangeType - } - } else { - $Entry.EffectiveChangeType = $RequiredChangeType - $Entry.EffectiveTargetVersion = $requiredVersion - if ($Entry.Source -eq 'user') { - $Entry.AutoUpgraded = $true - } - } -} - -# Records one cascade reason on an entry, keyed by target package name. -# Re-recording the same target on a later strengthening pass overwrites the -# prior reason in place instead of appending a duplicate, so the list stays -# one-per-target however many fixpoint iterations run. -# -# The key is a package, not a graph edge. CascadeReasons answers "which -# released packages forced this entry into the set, and did any of them force -# it breaking?", and its entries arrive from two different relationships: -# -# * MEMBERSHIP -- the target this entry was reached from during the -# Get-TransitivePublishedDependentsFromBaseline walk. That walk is -# transitive, so the target need not be a direct dependency; it can sit -# several crates away, possibly behind unpublished conduits. -# * EXPOSURE -- a target whose types this entry names in its own public API, -# recorded by Update-EntryForExposedDependency. Since re-exported types are -# attributed to their defining crate, this too can name a package the entry -# does not depend on directly. -# -# So a reason means "this package caused that release", not "this package is a -# direct dependency". Changelog attribution needs the narrower direct-dependency -# relation and must derive it from the dependency graph rather than reading it -# out of this collection. -function Set-CascadeReason { - param( - [Parameter(Mandatory = $true)][pscustomobject]$Entry, - [Parameter(Mandatory = $true)][pscustomobject]$Reason - ) - - for ($i = 0; $i -lt $Entry.CascadeReasons.Count; $i++) { - if ($Entry.CascadeReasons[$i].Target -eq $Reason.Target) { - $Entry.CascadeReasons[$i] = $Reason - return - } - } - - $Entry.CascadeReasons.Add($Reason) -} - -# Returns $true when this entry ships an API incompatible with its previous -# release -- whether or not the version number admits it. -# -# The version transition is the primary signal, derived from the versions -# rather than from EffectiveChangeType so that 0.x semantics are applied by -# the same rules that produced the number (under 0.x a minor bump is itself -# breaking). -# -# PinHonoredAgainstCascade is the second signal, and it is not redundant. It is -# set only when -Force honored an explicit pin BELOW a required version, which -# writes a numerically compatible version over an API that the requirement says -# is not. When the suppressed requirement was itself breaking, the break is -# real: a crate pinned to 1.1.0 while exposing a dependency that went 2.0.0 is -# still compiled against that 2.0.0, so its public API names different types -# than 1.0.0 did. Under caret semantics a consumer of `1.0` upgrades into it -# silently. Judging that entry by its version alone stops the cascade at -# exactly the crate whose break was suppressed, letting dependents ship -# compatible releases over it -- the silent SemVer break this cascade exists to -# prevent. -# -# -Force means "write my number and warn me", not "the incompatibility is not -# there". Propagation must follow the API, so it follows the unmet requirement -# too. -# -# The suppressed requirement is tested with the same Test-IsBreakingChange used -# for the planned transition, NOT compared against the literal 'breaking'. The -# flag is set for any suppressed requirement, including a merely additive one, -# and a forced non-breaking pin must not drag exposing dependents to a major -# release. Routing it through the same predicate also keeps 0.x semantics -# consistent between the two branches. -# -# Only the exposure fixpoint calls this, and it already skips proc-macro-only -# sources -- so a 'breaking' tag arising from proc-macro manual review, which -# need not imply any rustdoc-visible break, never reaches here. -function Test-EntryPlansBreakingRelease { - param( - [Parameter(Mandatory = $true)][pscustomobject]$Entry - ) - - if ($Entry.PinHonoredAgainstCascade -and - (Test-IsBreakingChange -oldVersion $Entry.CurrentVersion -ChangeType $Entry.EffectiveChangeType)) { - return $true - } - - $plannedChangeType = Get-ChangeTypeFromVersions ` - -oldVersion $Entry.CurrentVersion ` - -newVersion $Entry.EffectiveTargetVersion - - return Test-IsBreakingChange -oldVersion $Entry.CurrentVersion -ChangeType $plannedChangeType -} - -# Returns the baseline packages that must inherit a breaking bump from -# $TargetPackage: those already in the release set that reach it and name its -# types in their own public API. -# -# Two kinds of edge qualify: -# -# * A DIRECT dependency that may expose the target. "May" is the operative -# word: an absent or malformed allowlist fails closed here, because an -# unknown must not ship a break as compatible. -# * An INDIRECT dependency whose allowlist explicitly names the target. -# cargo-check-external-types attributes a re-exported type to its defining -# crate, so a crate reaching `a::T` through `b` allowlists `a` while -# depending only on `b`. Requiring a direct edge missed these entirely. -# The root matched here is the target's own crate root (its [lib] name), -# not a rename alias: renames live on edges, and an indirect dependent -# declares no edge to the target to rename. -# This branch demands positive evidence rather than failing closed, so it -# cannot drag in transitive dependents that merely lack metadata -- those -# are already covered by their own direct edges, walked up by the fixpoint. -# -# Proc-macro-only dependents are excluded because they have no rustdoc API to -# expose anything through; they reach the release set via the manual-review -# queue instead. -function Get-PublishedDependentsExposingTarget { - param( - [Parameter(Mandatory = $true)][pscustomobject]$TargetPackage, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline, - # Read-only: used only to test release-set membership. Typed as - # IDictionary rather than [hashtable] because the caller's dictionary is - # [ordered], which is not a Hashtable -- PowerShell would satisfy a - # [hashtable] annotation by silently substituting a converted copy. - [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Resolved, - # Optional memo of target cargo name -> reachable published dependent - # folders. The baseline is fixed for the duration of a resolve, so the - # BFS answer is stable; the fixpoint would otherwise recompute it for - # every breaking source on every pass. - [System.Collections.IDictionary]$TransitiveDependentCache - ) - - # Cargo dependency names use underscores; package names may use hyphens. - $targetCargoName = $TargetPackage.Name.Replace('-', '_') - - if ($null -ne $TransitiveDependentCache -and $TransitiveDependentCache.Contains($targetCargoName)) { - $reachable = $TransitiveDependentCache[$targetCargoName] - } else { - $reachable = [System.Collections.Generic.HashSet[string]]::new( - [string[]]@(Get-TransitivePublishedDependentsFromBaseline ` - -Baseline $WorkspaceBaseline -TargetCargoName $targetCargoName), - [System.StringComparer]::Ordinal) - if ($null -ne $TransitiveDependentCache) { - $TransitiveDependentCache[$targetCargoName] = $reachable - } - } - - return @($WorkspaceBaseline | Where-Object { - $_.Published -and - -not $_.IsProcMacroOnly -and - $Resolved.Contains($_.Folder) -and - $( - if ($_.Deps -contains $targetCargoName) { - Test-PackageExposesTarget -Dependent $_ -TargetPackageName $TargetPackage.Name - } else { - $reachable.Contains($_.Folder) -and - (Test-PackageAllowlistNamesTarget -Dependent $_ -TargetPackageName $TargetPackage.Name ` - -TargetCrateRoot $TargetPackage.CrateRoot) - } - ) - }) -} - -# Raises one dependent entry to 'breaking' because it exposes an incompatibly -# versioned dependency, records the reason, and returns $true when that actually -# strengthened the entry. The return value is what drives fixpoint termination: -# once a pass strengthens nothing, the cascade is complete. -function Update-EntryForExposedDependency { - param( - [Parameter(Mandatory = $true)][pscustomobject]$Entry, - [Parameter(Mandatory = $true)][string]$TargetPackageName, - [switch]$Force - ) - - $previousChangeType = $Entry.EffectiveChangeType - $previousTargetVersion = $Entry.EffectiveTargetVersion - - Update-EntryForRequiredChangeType -Entry $Entry -RequiredChangeType 'breaking' ` - -RequirementLabel 'exposed-dependency cascade' ` - -RequirementDetail "this crate's own public API naming types from the incompatible planned version of '$TargetPackageName'" ` - -Force:$Force - - Set-CascadeReason -Entry $Entry -Reason ([pscustomobject]@{ Target = $TargetPackageName; Breaking = $true }) - - return ($Entry.EffectiveChangeType -ne $previousChangeType -or - $Entry.EffectiveTargetVersion -ne $previousTargetVersion) -} - -# Turns the parsed token entries from Parse-ReleaseTokens into a *resolved -# release set* — every package that will receive a release in this invocation, -# whether the user asked for it directly or it was pulled in by cascade. -# -# Inputs: -# -ParsedTokens : the @() output of Parse-ReleaseTokens. -# -WorkspaceBaseline: an *immutable* snapshot of Get-WorkspacePackages, -# captured BEFORE any release writes are performed. The -# same snapshot must be passed to every Resolve-ReleaseSet -# call during a single release-packages run, otherwise -# cascade math would double-bump (the on-disk state -# mutates as releases land). -# -Force : if set, an explicit version pin that numerically -# undershoots the cascade-required version is honored -# verbatim instead of throwing. EffectiveChangeType is -# still upgraded to record the stronger unmet requirement; -# PinHonoredAgainstCascade lets callers warn the user and -# keeps the exposure cascade running past the pin, since -# the pin lowers the version number rather than the -# incompatibility of the API being shipped. -# -Force does NOT relax the always-fatal "pin is not -# strictly greater than the current on-disk version" check. -# -# Returns: an array of pscustomobject entries, one per resolved package: -# -# @{ -# Folder = '' -# Name = '' # may differ from Folder -# CurrentVersion = '' -# RequestedChangeType = 'breaking'|'non-breaking'|'patch'|$null # null for cascade-source -# RequestedTargetVersion = ''|$null # null when not pinned -# EffectiveChangeType = 'breaking'|'non-breaking'|'patch' # after cascade resolution -# EffectiveTargetVersion = '' # after cascade resolution -# Source = 'user'|'cascade' -# AutoUpgraded = $true|$false # user-source entry strengthened by cascade -# PinHonoredAgainstCascade = $true|$false # -Force kept an explicit pin below cascade-required version -# IsProcMacroOnly = $true|$false # cargo metadata target classification -# RequiresManualSemverReview = $true|$false # proc-macro API cannot be checked automatically -# CascadeReasons = [List<{Target,Breaking}>] # one per target package cause -# RawToken = ''|$null # null for cascade-source -# } -# -# Resolution algorithm: -# 1. Seed every token as a user-source entry. Reject: -# - tokens for non-workspace packages -# - tokens for unpublished workspace packages -# - explicit version pins not strictly greater than the current version -# 2. For each user-source entry, BFS via -# Get-TransitivePublishedDependentsFromBaseline to collect all published -# transitive dependents. For each dependent compute the cascade-applied -# change type from exposing/non-exposing semantics, and either: -# - upgrade an existing entry (rank-ordered: patch < non-breaking < -# breaking). For user-source entries with -Change keyword, -# auto-upgrade silently and set AutoUpgraded=$true. For user-source -# entries with an explicit version pin, throw if the pin would -# numerically undershoot the cascade-required version (or, with -# -Force, honor the pin verbatim, record the unmet requirement in the -# change-type tag, and set PinHonoredAgainstCascade=$true); otherwise -# honour the pin and bump only the diagnostic change-type tag. -# - or create a new cascade-source entry. -# Ordinary library dependents use their own cargo-semver-checks result, -# floored at patch. A second pass raises a dependent to breaking when it -# exposes a dependency that ships an incompatible API -- an incompatible -# planned version transition, or a forced pin that suppressed one. -# That pass considers direct dependency edges (failing closed on absent or -# malformed allowlist metadata) plus indirect edges to a transitive -# dependency whose types the dependent explicitly allowlists, which is how -# a re-exported type -- attributed by cargo-check-external-types to its -# defining crate -- is caught. The pass repeats to a fixpoint so exposure -# chains cascade. -# Proc-macro-only dependents use a provisional patch floor and are explicitly -# classified in the interactive review. -# Cascade reasons are package-level release causes, recorded once per -# target package name for membership or exposure strengthening. They do -# not assert a direct dependency edge; callers needing edges must query -# the dependency graph. -# -# The release-set membership walk starts from user targets and includes every -# transitive published dependent. Severity is then resolved from two independent -# signals: cargo-semver-checks analyses each crate's own API diff, while -# allowed_external_types detects incompatible version changes in dependencies -# exposed through otherwise-unchanged public signatures. A proc-macro-only -# dependent starts at the mechanical patch floor and is then classified by the -# user in the standard review dialog. -function Resolve-ReleaseSet { - param( - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$ParsedTokens, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline, - # Classifier scriptblock: (folder, cargoName) -> 'breaking'|'non-breaking'| - # 'patch'|'none' for ordinary libraries. Decides each crate's minimum - # change type from its real API diff (cargo-semver-checks) vs its previous - # version-bump commit. Proc-macro-only packages bypass it and enter manual - # review. Production passes $script:DefaultSemverClassifier (which calls - # Get-CrateRequiredChangeType); the default here is a no-op ('none') so - # callers that only exercise version/pin/BFS math need not supply one. - [Parameter(Mandatory = $false)][scriptblock]$GetRequiredChangeType = { param($folder, $cargoName) 'none' }, - [Parameter(Mandatory = $false)][switch]$Force - ) - - if ($null -eq $ParsedTokens -or @($ParsedTokens).Count -eq 0) { - throw "Resolve-ReleaseSet: ParsedTokens is empty. Parse-ReleaseTokens should reject empty input earlier." - } - - $baselineByFolder = @{} - $baselineByCargo = @{} - foreach ($pkg in $WorkspaceBaseline) { - $baselineByFolder[$pkg.Folder] = $pkg - $baselineByCargo[$pkg.Name.Replace('-', '_')] = $pkg - } - - $resolved = [ordered]@{} - - foreach ($req in $ParsedTokens) { - $pkg = $baselineByFolder[$req.Name] - if ($null -eq $pkg) { - $normalizedReq = $req.Name.Replace('-', '_') - $pkg = $baselineByCargo[$normalizedReq] - } - if ($null -eq $pkg) { - throw "Package '$($req.Name)' is not part of the workspace (no folder under 'crates/' and no Cargo package by that name). Token: '$($req.RawToken)'." - } - if (-not $pkg.Published) { - throw "Package '$($pkg.Folder)' has 'publish = false' in its Cargo.toml; only published packages can be released. Token: '$($req.RawToken)'." - } - - if ($resolved.Contains($pkg.Folder)) { - throw "Internal error: package '$($pkg.Folder)' resolved twice from -Packages (token '$($req.RawToken)'). Parse-ReleaseTokens should have rejected the duplicate earlier." - } - - $currentVersion = $pkg.Version - - if (-not [string]::IsNullOrEmpty($req.RequestedTargetVersion)) { - $target = $req.RequestedTargetVersion - $cmp = Compare-SemanticVersions -version1 $target -version2 $currentVersion - if ($cmp -le 0) { - throw "Cannot release '$($pkg.Folder)' as v$($target): package is already at v$currentVersion. Explicit version pins must be strictly greater than the current version. Token: '$($req.RawToken)'." - } - $effectiveChangeType = Get-ChangeTypeFromVersions -oldVersion $currentVersion -newVersion $target - $effectiveTargetVersion = $target - } else { - $effectiveChangeType = $req.RequestedChangeType - $effectiveTargetVersion = Get-NextVersion -currentVersion $currentVersion -ChangeType $effectiveChangeType - } - - $resolved[$pkg.Folder] = [pscustomobject]@{ - Folder = $pkg.Folder - Name = $pkg.Name - CurrentVersion = $currentVersion - RequestedChangeType = $req.RequestedChangeType - RequestedTargetVersion = $req.RequestedTargetVersion - EffectiveChangeType = $effectiveChangeType - EffectiveTargetVersion = $effectiveTargetVersion - Source = 'user' - AutoUpgraded = $false - PinHonoredAgainstCascade = $false - IsProcMacroOnly = [bool]$pkg.IsProcMacroOnly - RequiresManualSemverReview = [bool]$pkg.IsProcMacroOnly - CascadeReasons = New-Object 'System.Collections.Generic.List[object]' - RawToken = $req.RawToken - } - } - - # Snapshot the folders requested for release before cascade adds pulled-in - # entries: these are the origins the cascade expands from, and everything - # added later is derived from them. Must stay a snapshot -- the loops below - # add to $resolved, and $resolved.Keys is a live view that throws if - # enumerated during mutation. - $requestedFolders = @($resolved.Keys) | ForEach-Object { $_ } - - # Self-floor requested folders before dependency exposure is evaluated. A - # crate the caller requested as patch may itself require a breaking release, - # and that stronger planned transition must cascade to consumers exposing - # its types. - foreach ($folder in $requestedFolders) { - $entry = $resolved[$folder] - if ($entry.IsProcMacroOnly) { continue } - $required = & $GetRequiredChangeType $entry.Folder $entry.Name - if ($required -eq 'manual') { - throw "Internal error: '$($entry.Name)' requires manual SemVer review but cargo metadata did not classify it as proc-macro-only." - } - if ([string]::IsNullOrEmpty($required) -or $required -eq 'none') { continue } - Update-EntryForRequiredChangeType -Entry $entry -RequiredChangeType $required ` - -RequirementLabel 'cargo-semver-checks' -RequirementDetail "the crate's own public API changes" -Force:$Force - } - - foreach ($targetFolder in $requestedFolders) { - $targetEntry = $resolved[$targetFolder] - $targetPkg = $baselineByFolder[$targetFolder] - - $targetCargoNorm = $targetPkg.Name.Replace('-', '_') - $reachable = Get-TransitivePublishedDependentsFromBaseline -Baseline $WorkspaceBaseline -TargetCargoName $targetCargoNorm - - foreach ($depFolder in $reachable) { - $depPkg = $baselineByFolder[$depFolder] - - # Ordinary library dependents are classified from their own API diff - # and floored at patch because they must re-release to pick up the new - # dependency version. cargo-semver-checks deliberately has no - # proc-macro-only API surface, so those dependents keep the mechanical - # patch floor until the interactive manual-review queue asks the user - # to inspect their diff and choose the final change type. - if ($depPkg.IsProcMacroOnly) { - $dependentChangeType = 'patch' - } else { - $classifiedChangeType = & $GetRequiredChangeType $depPkg.Folder $depPkg.Name - if ($classifiedChangeType -eq 'manual') { - throw "Internal error: '$($depPkg.Name)' requires manual SemVer review but cargo metadata did not classify it as proc-macro-only." - } - $dependentChangeType = Get-StrongerChangeType 'patch' $classifiedChangeType - } - - $depBreakingForReason = Test-IsBreakingChange -oldVersion $depPkg.Version -ChangeType $dependentChangeType - $cascadeReason = [pscustomobject]@{ - Target = $targetPkg.Name - Breaking = $depBreakingForReason - } - - if ($resolved.Contains($depFolder)) { - $existing = $resolved[$depFolder] - - # Dedup cascade reasons by target name (re-encountering the - # same target after a strengthening pass overwrites the prior - # reason in place rather than adding a duplicate). This target - # was reached transitively, so it is not necessarily a direct - # dependency of $depFolder -- see Set-CascadeReason. - Set-CascadeReason -Entry $existing -Reason $cascadeReason - - $reasonsNames = ($existing.CascadeReasons | ForEach-Object { $_.Target } | Sort-Object -Unique) -join ', ' - Update-EntryForRequiredChangeType -Entry $existing -RequiredChangeType $dependentChangeType ` - -RequirementLabel 'cascade' -RequirementDetail "changes in: $reasonsNames" -Force:$Force - } else { - $newEntry = [pscustomobject]@{ - Folder = $depPkg.Folder - Name = $depPkg.Name - CurrentVersion = $depPkg.Version - RequestedChangeType = $null - RequestedTargetVersion = $null - EffectiveChangeType = $dependentChangeType - EffectiveTargetVersion = Get-NextVersion -currentVersion $depPkg.Version -ChangeType $dependentChangeType - Source = 'cascade' - AutoUpgraded = $false - PinHonoredAgainstCascade = $false - IsProcMacroOnly = [bool]$depPkg.IsProcMacroOnly - RequiresManualSemverReview = [bool]$depPkg.IsProcMacroOnly - CascadeReasons = New-Object 'System.Collections.Generic.List[object]' - RawToken = $null - } - $newEntry.CascadeReasons.Add($cascadeReason) - $resolved[$depPkg.Folder] = $newEntry - } - } - } - - # cargo-semver-checks compares one crate's rustdoc API and cannot identify - # that an unchanged signature now names a type from an incompatible version - # of an external crate. Propagate that condition over dependency edges -- - # direct, plus indirect edges where a re-exported type is allowlisted under - # its defining crate -- until no dependent is strengthened. Each source is - # classified by Test-EntryPlansBreakingRelease, which asks whether the entry - # ships an incompatible API: normally the version it will actually write, - # and additionally a -Force pin that suppressed a breaking requirement, - # since such a pin lowers the version number without removing the break. - $transitiveDependentCache = @{} - $exposureChanged = $true - while ($exposureChanged) { - $exposureChanged = $false - - foreach ($sourceEntry in @($resolved.Values)) { - $sourcePkg = $baselineByFolder[$sourceEntry.Folder] - if ($null -eq $sourcePkg -or $sourcePkg.IsProcMacroOnly) { continue } - if (-not (Test-EntryPlansBreakingRelease -Entry $sourceEntry)) { continue } - - $dependentPkgs = Get-PublishedDependentsExposingTarget -TargetPackage $sourcePkg ` - -WorkspaceBaseline $WorkspaceBaseline -Resolved $resolved ` - -TransitiveDependentCache $transitiveDependentCache - - foreach ($dependentPkg in $dependentPkgs) { - $strengthened = Update-EntryForExposedDependency ` - -Entry $resolved[$dependentPkg.Folder] ` - -TargetPackageName $sourcePkg.Name ` - -Force:$Force - - if ($strengthened) { $exposureChanged = $true } - } - } - } - - return @($resolved.Values) -} - -# Builds the mandatory manual-review queue for: -# * every proc-macro-only package already present in the release set, and -# * direct published consumers of a manually reviewed breaking entry. -# -# The second category advances one dependency edge at a time. A consumer is not -# allowed to propagate the review farther until its own standard dialog has -# completed and its final planned increment is breaking. Keeping or selecting a -# non-breaking/patch level stops the review chain at that package. All transitive -# published dependents are still present in the normal cascade release set and -# retain their cargo-semver-checks classification. -function Get-ManualSemverReviewFindings { - param( - [Parameter(Mandatory = $true)][hashtable]$ResolvedReleaseSet, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline, - [Parameter(Mandatory = $false)][hashtable]$ModifiedSnapshot, - [Parameter(Mandatory = $false)][AllowEmptyCollection()][System.Collections.Generic.HashSet[string]]$ReviewedManualSemver - ) - - $byFolder = @{} - foreach ($pkg in $WorkspaceBaseline) { $byFolder[$pkg.Folder] = $pkg } - - if ($null -eq $ReviewedManualSemver) { - $ReviewedManualSemver = [System.Collections.Generic.HashSet[string]]::new() - } - - $findingsByFolder = [ordered]@{} - foreach ($entry in @($ResolvedReleaseSet.Values | Sort-Object -Property Folder)) { - if (-not $entry.IsProcMacroOnly) { continue } - $pkg = $byFolder[$entry.Folder] - if ($null -eq $pkg) { continue } - - $changedFileCount = 0 - if ($null -ne $ModifiedSnapshot -and $ModifiedSnapshot.ContainsKey($entry.Folder)) { - $changedFileCount = $ModifiedSnapshot[$entry.Folder] - } - - $findingsByFolder[$entry.Folder] = [pscustomobject]@{ - Folder = $entry.Folder - PackageName = $entry.Name - CurrentVersion = $entry.CurrentVersion - InReleaseSet = $true - PlannedCurrentVersion = $entry.CurrentVersion - EffectiveChangeType = $entry.EffectiveChangeType - EffectiveTargetVersion = $entry.EffectiveTargetVersion - ChangedFileCount = $changedFileCount - DependencyChains = @() - WorkspaceDependencyChains = Get-InWorkspaceDependencyChains -Packages $WorkspaceBaseline -TargetFolder $entry.Folder - RequiresManualSemverReview = $true - ManualSemverReviewKind = 'proc-macro' - ManualSemverReviewSources = @() - } - } - - foreach ($sourceFolder in @($ReviewedManualSemver | Sort-Object)) { - if (-not $ResolvedReleaseSet.ContainsKey($sourceFolder)) { continue } - $sourceEntry = $ResolvedReleaseSet[$sourceFolder] - # -Force can intentionally keep an explicit version pin below the - # required severity while upgrading only EffectiveChangeType for cascade - # bookkeeping. Manual review propagation follows the version that will - # actually be written, matching CI, not that stronger internal tag. - $plannedChangeType = Get-ChangeTypeFromVersions ` - -oldVersion $sourceEntry.CurrentVersion ` - -newVersion $sourceEntry.EffectiveTargetVersion - if (-not (Test-IsBreakingChange -oldVersion $sourceEntry.CurrentVersion -ChangeType $plannedChangeType)) { - continue - } - - $sourcePkg = $byFolder[$sourceFolder] - if ($null -eq $sourcePkg) { continue } - $sourceCargoName = $sourcePkg.Name.Replace('-', '_') - - $directDependents = Get-DirectPublishedDependentsFromBaseline ` - -Baseline $WorkspaceBaseline ` - -TargetCargoName $sourceCargoName - - foreach ($dependentFolder in $directDependents) { - if (-not $ResolvedReleaseSet.ContainsKey($dependentFolder)) { - throw "Internal error: direct published dependent '$dependentFolder' of manually reviewed breaking package '$sourceFolder' is missing from the resolved release set." - } - - if ($findingsByFolder.Contains($dependentFolder)) { - $existingSources = @($findingsByFolder[$dependentFolder].ManualSemverReviewSources) - $findingsByFolder[$dependentFolder].ManualSemverReviewSources = @( - $existingSources + $sourceEntry.Name | Sort-Object -Unique - ) - continue - } - - $dependentEntry = $ResolvedReleaseSet[$dependentFolder] - $dependentPkg = $byFolder[$dependentFolder] - $changedFileCount = 0 - if ($null -ne $ModifiedSnapshot -and $ModifiedSnapshot.ContainsKey($dependentFolder)) { - $changedFileCount = $ModifiedSnapshot[$dependentFolder] - } - - $findingsByFolder[$dependentFolder] = [pscustomobject]@{ - Folder = $dependentEntry.Folder - PackageName = $dependentEntry.Name - CurrentVersion = $dependentEntry.CurrentVersion - InReleaseSet = $true - PlannedCurrentVersion = $dependentEntry.CurrentVersion - EffectiveChangeType = $dependentEntry.EffectiveChangeType - EffectiveTargetVersion = $dependentEntry.EffectiveTargetVersion - ChangedFileCount = $changedFileCount - DependencyChains = @() - WorkspaceDependencyChains = Get-InWorkspaceDependencyChains -Packages $WorkspaceBaseline -TargetFolder $dependentFolder - RequiresManualSemverReview = $true - ManualSemverReviewKind = 'proc-macro-dependent' - ManualSemverReviewSources = @($sourceEntry.Name) - } - } - } - - return @($findingsByFolder.Values) -} - -# Persists completed manual-review provenance on final resolved entries so the -# release-plan display records both proc-macro classification and every -# downstream review performed because a direct dependency remained breaking. -function Set-ManualSemverReviewAnnotations { - param( - [Parameter(Mandatory = $true)][hashtable]$ResolvedReleaseSet, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][System.Collections.Generic.HashSet[string]]$ReviewedManualSemver, - [Parameter(Mandatory = $false)][hashtable]$ModifiedSnapshot - ) - - $findings = Get-ManualSemverReviewFindings ` - -ResolvedReleaseSet $ResolvedReleaseSet ` - -WorkspaceBaseline $WorkspaceBaseline ` - -ModifiedSnapshot $ModifiedSnapshot ` - -ReviewedManualSemver $ReviewedManualSemver - $findingByFolder = @{} - foreach ($finding in $findings) { $findingByFolder[$finding.Folder] = $finding } - - foreach ($entry in $ResolvedReleaseSet.Values) { - $completed = $ReviewedManualSemver.Contains($entry.Folder) - $kind = $null - $sources = @() - if ($completed -and $findingByFolder.ContainsKey($entry.Folder)) { - $kind = $findingByFolder[$entry.Folder].ManualSemverReviewKind - $sources = @($findingByFolder[$entry.Folder].ManualSemverReviewSources) - } - - $entry | Add-Member -NotePropertyName ManualSemverReviewCompleted -NotePropertyValue $completed -Force - $entry | Add-Member -NotePropertyName ManualSemverReviewKind -NotePropertyValue $kind -Force - $entry | Add-Member -NotePropertyName ManualSemverReviewSources -NotePropertyValue $sources -Force - } -} - -# Repo root for the default classifier, set by Invoke-ReleasePackagesMain before -# planning. A module-scope variable (rather than a captured closure) so the -# classifier scriptblock below resolves it in the module session state. -$script:ReleaseRepoRoot = $null - -# The production classifier handed to Resolve-ReleaseSet / Invoke-PlanReview. -# Defined at module scope so, when invoked via `& $GetRequiredChangeType` deep -# inside the planner, it resolves Get-CrateRequiredChangeType and -# $script:ReleaseRepoRoot in this module's session state — and Pester can Mock -# Get-CrateRequiredChangeType for tests. -$script:DefaultSemverClassifier = { - param([string]$folder, [string]$cargoName) - Get-CrateRequiredChangeType -Folder $folder -CargoName $cargoName -RepoRoot $script:ReleaseRepoRoot -} - -function Format-ConventionalCommits { - param( - [string[]]$rawCommitMessages, - [string]$prBaseUrl - ) - - if (-not $rawCommitMessages) { - return @() - } - - $groupedCommits = [ordered]@{} - - foreach ($message in $rawCommitMessages) { - $type = "miscellaneous" - $description = $message - $isConventional = $false - - $conventionalMatch = $script:ConventionalCommitRegex.Match($message) - $isBreaking = $false - if ($conventionalMatch.Success) { - $type = $conventionalMatch.Groups[1].Value - $isBreaking = $conventionalMatch.Groups[2].Value -eq '!' - $description = $conventionalMatch.Groups[3].Value - $isConventional = $true - } - - if ($isConventional -and $script:IgnoredTypes -contains $type) { - continue - } - - if (-not [string]::IsNullOrEmpty($prBaseUrl)) { - $prMatch = $script:PrReferenceRegex.Match($description) - if ($prMatch.Success) { - $fullMatch = $prMatch.Groups[0].Value - $prNumber = $prMatch.Groups[2].Value - $prLink = " ([#$prNumber]($prBaseUrl/$prNumber))" - $description = $description.Substring(0, $description.Length - $fullMatch.Length) + $prLink - } - } - - # Breaking changes are grouped separately, regardless of the commit type - $groupKey = if ($isBreaking) { - 'breaking' - } elseif ($script:TypeGroupMapping.ContainsKey($type)) { - $script:TypeGroupMapping[$type] - } else { - $type - } - - if (-not $groupedCommits.Contains($groupKey)) { - $groupedCommits[$groupKey] = [System.Collections.ArrayList]::new() - } - - [void]$groupedCommits[$groupKey].Add(" - $description") - } - - $sortedKeys = Sort-KeysByPreferredOrder -allKeys $groupedCommits.Keys -preferredOrder $script:TypeOrder - $formattedLines = @() - foreach ($type in $sortedKeys) { - if ($groupedCommits[$type].Count -gt 0) { - $headerName = if ($script:HeaderNameMapping.ContainsKey($type)) { $script:HeaderNameMapping[$type] } else { $type.Substring(0, 1).ToUpper() + $type.Substring(1) } - $formattedLines += @("- $headerName", "") + @($groupedCommits[$type]) + @("") - } - } - - if ($formattedLines.Count -gt 0 -and [string]::IsNullOrWhiteSpace($formattedLines[-1])) { - if ($formattedLines.Count -gt 1) { - $formattedLines = $formattedLines[0..($formattedLines.Count - 2)] - } else { - $formattedLines = @() - } - } - - return $formattedLines -} - -# --- SCRIPT FUNCTIONS --- - -function Update-PackageVersion { - param( - [string]$packageName, - [string]$version, - [string]$packageCargoToml, - [string]$rootCargoToml - ) - - if ([string]::IsNullOrEmpty($version)) { - Write-Error 'Update-PackageVersion: -version is required.' -ErrorAction Stop - } - - Write-Host "📝 Updating '$packageCargoToml'..." - $packageContent = Get-Content $packageCargoToml -Raw - # Scope the version replacement to the [package] table via the shared regex - # in releasing.ps1, which anchors to line starts so substring keys like - # `rust-version` cannot match and inline workspace-dep `version = "..."` - # declarations later in the file are left alone. Replace exactly once. - if (-not $script:CargoPackageVersionRegex.IsMatch($packageContent)) { - Write-Error "Could not find [package] version line in '$packageCargoToml'." -ErrorAction Stop - } - $packageContent = $script:CargoPackageVersionRegex.Replace($packageContent, ('${1}' + $version), 1) - Set-Content -LiteralPath $packageCargoToml -Value $packageContent -NoNewline -Encoding utf8 - - Write-Host "📝 Updating '$rootCargoToml'..." - - function Get-EscapedRegexSpecialChars($str) { - # Escape all regex metacharacters: . $ ^ { [ ( | ) * + ? \ / - # The replacement string `\$1` produces a literal backslash followed by - # the matched metacharacter — `\` is a literal in .NET replacement-string - # syntax (not an escape) and `$1` is the group-1 backreference. Do NOT - # use `\\$1` here: that double-escapes (e.g. `1.2.3` -> `1\\.2\\.3`). - return ($str -replace $script:RegexEscapeRegex, '\$1') - } - - $escapedPackageName = Get-EscapedRegexSpecialChars($packageName) - $packageNamePattern = $escapedPackageName.Replace('_', '[-_]') - # Anchor the lookbehind to the start of a line (multiline mode) so the - # package name cannot match as a suffix of another crate's name. Without - # `^`, releasing e.g. `bar` would also rewrite `foo_bar = { ..., version - # = "..." }` because the regex engine can satisfy the lookbehind by - # matching `bar` against the trailing 3 chars of `foo_bar`. Workspace - # dependency declarations in the root Cargo.toml are conventionally one - # per line and flush-left, matching the layout produced by the test - # fixture's `Write-RootCargoToml`. - $regex = '(?m)(?<=^' + $packageNamePattern + '\s*=\s*\{[^\}]*?version\s*=\s*")[^"]+' - (Get-Content -LiteralPath $rootCargoToml -Raw) -replace $regex, $version | Set-Content -LiteralPath $rootCargoToml -NoNewline -Encoding utf8 - - return $version -} - - -# Locates a `## Unreleased` (or `## [Unreleased]`, case-insensitive) Markdown -# section in a changelog string and extracts its body lines, returning content -# with the section removed. Used by Write-Changelog to fold a manually-curated -# Unreleased section's contents into the new version section being created. -# -# Inputs: -# -Content : full changelog text (the kind returned by Get-Content -Raw). -# -# Returns: -# $null if no Unreleased section is present. -# Otherwise a [pscustomobject] with: -# BodyLines - string[] (always an array; possibly empty) — the -# section's body split into lines, with leading -# and trailing blank lines stripped. Internal blank -# lines are preserved. -# ContentWithoutSection - string — original content with the matched -# section (header + body) removed. -# -# Match semantics: -# - Header line matches `## Unreleased` or `## [Unreleased]`, with optional -# trailing whitespace. Case-insensitive on the word `Unreleased`. -# - Body spans from the line after the header up to (but not including) the -# next `## ` line at column 0, or end-of-input. -# - Only the FIRST Unreleased section is extracted (it is unconventional for -# a changelog to contain more than one). -function Extract-UnreleasedSection { - param( - [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content - ) - - if ([string]::IsNullOrEmpty($Content)) { - return $null - } - - # (?ims) — Multiline (^ matches line starts) + Singleline (. matches - # newlines, so the non-greedy body can span lines) + IgnoreCase. - $pattern = '(?ims)^##[ \t]+(?:\[Unreleased\]|Unreleased)[ \t]*\r?\n(?.*?)(?=^##[ \t]|\z)' - $match = [regex]::Match($Content, $pattern) - if (-not $match.Success) { - return $null - } - - $body = $match.Groups['body'].Value - $lines = @($body -split "`r?`n") - - # Strip trailing blank lines. - while ($lines.Count -gt 0 -and [string]::IsNullOrWhiteSpace($lines[-1])) { - $lines = if ($lines.Count -eq 1) { @() } else { @($lines[0..($lines.Count - 2)]) } - } - # Strip leading blank lines. - while ($lines.Count -gt 0 -and [string]::IsNullOrWhiteSpace($lines[0])) { - $lines = if ($lines.Count -eq 1) { @() } else { @($lines[1..($lines.Count - 1)]) } - } - - return [pscustomobject]@{ - BodyLines = [string[]]$lines - ContentWithoutSection = $Content.Remove($match.Index, $match.Length) - } -} - - -function Write-Changelog { - param( - [string]$packageName, - [string]$newVersion, - [string]$packageFolder, - [string]$changelogFile, - [string]$prBaseUrl, - # Optional: when this package is being released as a cascade-from-dependency, - # describe one or more cascades so a maintenance/breaking entry can be - # written even if the package has no commits since its last release. Each - # element shape: @{ Target = ''; Version = ''; Breaking = $false }. - # The section header is `⚠️ Breaking` if ANY reason is Breaking, otherwise - # `🔧 Maintenance`; one bullet is emitted per reason in deterministic - # (Target-sorted) order. Element shape is duck-typed (.Target / .Version / - # .Breaking) so both hashtables and [pscustomobject] are accepted. - [object[]]$cascadeReasons = $null - ) - - $hasCascade = ($null -ne $cascadeReasons) -and ($cascadeReasons.Count -gt 0) - - # Read the existing changelog up front and extract any `## Unreleased` - # section. The body of that section will be folded into the new version - # section we're about to create — leaving it behind would orphan - # manually-curated release notes below the freshly-inserted version - # heading. Unreleased presence alone is enough reason to write a new - # section, so we check it in the no-content guard below. - $existingContent = $null - $existingHadContent = $false - $unreleasedLines = @() - if (Test-Path $changelogFile) { - $existingContent = Get-Content $changelogFile -Raw - if ($existingContent) { - $existingHadContent = $true - $extracted = Extract-UnreleasedSection -Content $existingContent - if ($null -ne $extracted) { - $unreleasedLines = $extracted.BodyLines - $existingContent = $extracted.ContentWithoutSection - } - } - } - - $hasUnreleased = $unreleasedLines.Count -gt 0 - - $tags = Invoke-Git -Arguments @('tag', '--list', "$packageName-v*") - $latestTag = $null - if ($null -eq $tags -or $tags.Count -eq 0) { - Write-Warning "No tags found for package '$packageName'. Generating changelog from all history." - } else { - $filteredTags = @($tags | Where-Object { $_ -match "^${packageName}-v\d+\.\d+\.\d+$" }) - if ($filteredTags.Count -gt 0) { - $sortedTags = @($filteredTags | Sort-Object { [version]($_ -replace "${packageName}-v", '') }) - $latestTag = $sortedTags[-1] - } else { - Write-Warning "No valid semantic version tags found for package '$packageName'. Generating changelog from all history." - } - } - - $currentDate = (Get-Date).ToString('yyyy-MM-dd') - - # Get commits since the latest tag (unreleased commits) - $range = if ($latestTag) { "$latestTag..HEAD" } else { "HEAD" } - $rawCommits = Invoke-Git -Arguments @('log', $range, '--pretty=format:%s', '--', $packageFolder) - if ($null -eq $rawCommits -or $rawCommits.Count -eq 0) { - $rawCommits = @() - } else { - $rawCommits = @($rawCommits) - } - - $formattedCommits = @() - if ($rawCommits.Count -gt 0) { - $formattedCommits = Format-ConventionalCommits -rawCommitMessages $rawCommits -prBaseUrl $prBaseUrl - } - - if ($formattedCommits.Count -eq 0 -and -not $hasCascade -and -not $hasUnreleased) { - if ($rawCommits.Count -eq 0) { - Write-Warning "No unreleased commits found to add to the changelog." - } else { - $filteredCount = $rawCommits.Count - $noun = if ($filteredCount -eq 1) { 'commit was' } else { 'commits were' } - Write-Warning "No relevant commits found to add to the changelog (all $filteredCount $noun filtered out)." - } - return - } - - # Prepend cascade entries when this package is being released because one - # (or more) of its dependencies was released. Emits structured - # "Now requires of " bullets — deliberately formal - # rather than colloquial — under the appropriate section: - # - 🔧 Maintenance (when no contributing cascade is breaking) - # - ⚠️ Breaking (when at least one contributing cascade is breaking) - # Bullets are sorted by Target name for deterministic output across runs. - # If the same section header was already produced by - # Format-ConventionalCommits for this release, the cascade bullets are - # merged into that existing section instead of creating a duplicate header. - if ($hasCascade) { - $anyBreaking = $false - foreach ($r in $cascadeReasons) { - if ([bool]$r.Breaking) { $anyBreaking = $true; break } - } - $sectionHeader = if ($anyBreaking) { '- ⚠️ Breaking' } else { '- 🔧 Maintenance' } - - $sortedReasons = @($cascadeReasons | Sort-Object -Property @{ Expression = { $_.Target } }) - $cascadeBullets = @($sortedReasons | ForEach-Object { - " - Now requires ``$($_.Version)`` of ``$($_.Target)``" - }) - - $existingHeaderIdx = -1 - for ($i = 0; $i -lt $formattedCommits.Count; $i++) { - if ($formattedCommits[$i] -eq $sectionHeader) { - $existingHeaderIdx = $i - break - } - } - - if ($existingHeaderIdx -ge 0) { - # Find the end of this section (next top-level "- " header or end of list). - $insertIdx = $formattedCommits.Count - for ($i = $existingHeaderIdx + 1; $i -lt $formattedCommits.Count; $i++) { - if ($formattedCommits[$i] -match '^- \S') { $insertIdx = $i; break } - } - # Trim trailing blank lines belonging to the section. - while ($insertIdx -gt $existingHeaderIdx + 1 -and [string]::IsNullOrWhiteSpace($formattedCommits[$insertIdx - 1])) { - $insertIdx-- - } - $before = if ($insertIdx -gt 0) { @($formattedCommits[0..($insertIdx - 1)]) } else { @() } - $after = if ($insertIdx -lt $formattedCommits.Count) { @($formattedCommits[$insertIdx..($formattedCommits.Count - 1)]) } else { @() } - $formattedCommits = $before + $cascadeBullets + $after - } else { - $cascadeLines = @($sectionHeader, "") + $cascadeBullets - if ($formattedCommits.Count -gt 0) { - $formattedCommits = $cascadeLines + @("") + $formattedCommits - } else { - $formattedCommits = $cascadeLines - } - } - } - - # Build the new version section. User-curated `## Unreleased` body lines - # (if any) lead the section so the manually-authored narrative appears - # first; cascade bullets + commit-derived bullets follow as supplementary - # detail. A blank line separates the two groups when both are present. - $newVersionSection = @("## [$newVersion] - $currentDate", "") - if ($hasUnreleased) { - $newVersionSection += $unreleasedLines - if ($formattedCommits.Count -gt 0) { - $newVersionSection += "" - } - } - $newVersionSection += $formattedCommits - $newVersionSection += "" - - # Insert the new version section into the existing changelog, using the - # Unreleased-stripped content as the base (so the orphaned `## Unreleased` - # heading is no longer present in the output). - if ($existingHadContent) { - # Find the position after "# Changelog" header and any blank lines - # Insert the new version section there - $headerPattern = '^# Changelog\s*\r?\n(\r?\n)*' - if ($existingContent -match $headerPattern) { - # Match the existing file's line-ending convention so we don't introduce - # mixed endings (e.g. CRLF body + LF for the new section). - $eol = Get-FileLineEnding -Path $changelogFile - $headerMatch = [regex]::Match($existingContent, $headerPattern) - $insertPosition = $headerMatch.Index + $headerMatch.Length - $newContent = $existingContent.Substring(0, $insertPosition) + - ($newVersionSection -join $eol) + $eol + - $existingContent.Substring($insertPosition) - Set-Content -LiteralPath $changelogFile -Value $newContent -NoNewline -Encoding utf8 - Write-Host "✅ Changelog updated at '$changelogFile'." - return - } - } - - # If no existing changelog or couldn't parse it, create a new one. - # No existing file to sample from, so default to LF (modern convention; matches - # what .gitattributes normalizes to in repos that enforce it). - $changelogContent = @("# Changelog", "") - $changelogContent += $newVersionSection - Set-Content -LiteralPath $changelogFile -Value (($changelogContent -join "`n") + "`n") -NoNewline -Encoding utf8 - Write-Host "✅ Changelog created at '$changelogFile'." -} - -function Update-Readme { - param( - [string]$packageName, - [string]$packageFolder - ) - - $readmeTemplate = Join-Path $packageFolder "../README.j2" - if (-not (Test-Path $readmeTemplate)) { - Write-Warning "README template not found at '$readmeTemplate'. Skipping README generation." - return - } - - if (-not (Test-CommandExists -command "cargo-doc2readme")) { - Write-Warning "cargo-doc2readme is not installed. Skipping README generation. Install with: cargo install cargo-doc2readme" - return - } - - Write-Host "📝 Updating README.md..." - Push-Location $packageFolder - try { - $result = cargo doc2readme --lib --template ../README.j2 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to generate README: $result" - } else { - Write-Host "✅ README.md updated." - } - } - finally { - Pop-Location - } -} - - -function Show-ReleaseSummary { - param( - [array]$releases - ) - - Write-Host "" - Write-Host "📦 Released packages:" -ForegroundColor Green - foreach ($r in @($releases | Sort-Object -Property Package)) { - Write-Host " - $($r.Package): $($r.OldVersion) -> $($r.NewVersion)" -ForegroundColor Green - } - Write-Host "" -} - -function Test-InteractiveSession { - if ($env:CI) { return $false } - if ($env:GITHUB_ACTIONS) { return $false } - try { if ([Console]::IsInputRedirected) { return $false } } catch { } - return $true -} - -# --- PER-PACKAGE MENU PROMPT FLOW --- -# -# Helpers backing Invoke-PlanReview's per-package menu. Split out so pure -# formatting can be unit-tested without capturing host streams, and so the -# diff / opener side-effects can be mocked individually. - -# Tracks temp files produced by Show-PackageDiff so Invoke-PlanReview can -# delete them at the end of the run. The plan-review entrypoint save/restores -# this so nested or re-entrant invocations don't clobber an outer run's list. -$script:TempPackageDiffPaths = [System.Collections.Generic.List[string]]::new() - -# Returns $true when option 5 (Release as patch) would be numerically indistinguishable -# from option 4 (Release as non-breaking change) for the given current version. -# This is the case for Cargo 0.x.y versions, where the semver carve-out lumps -# the non-breaking and patch change types under the same numeric increment -# (0.x.(y+1)) — and on 0.0.x where every change type collapses to 0.0.(x+1). -# When CurrentVersion is unknown, we conservatively return $false so all -# options remain visible. -function Test-IsPatchOptionRedundant { - param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$CurrentVersion) - - if ([string]::IsNullOrWhiteSpace($CurrentVersion)) { return $false } - $nonBreakingNext = Get-NextVersion -currentVersion $CurrentVersion -ChangeType 'non-breaking' - $patchNext = Get-NextVersion -currentVersion $CurrentVersion -ChangeType 'patch' - return ($nonBreakingNext -eq $patchNext) -} - -# Returns $true when option 4 (Release as non-breaking change) would be -# numerically indistinguishable from option 3 (Release as breaking change) for -# the given current version. Under Cargo's 0.0.x semver carve-out every change -# type (breaking, non-breaking, patch) collapses to the same 0.0.(x+1) -# increment, so there is no point offering the non-breaking option — and the -# user should be told that all releases at this version range are considered -# breaking changes by Cargo. Returns $false for 0.x.y (y >= 1) where breaking -# (0.(y+1).0) still differs from non-breaking (0.y.(z+1)). When CurrentVersion -# is unknown we conservatively return $false so all options remain visible. -function Test-IsNonBreakingOptionRedundant { - param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$CurrentVersion) - - if ([string]::IsNullOrWhiteSpace($CurrentVersion)) { return $false } - $nonBreakingNext = Get-NextVersion -currentVersion $CurrentVersion -ChangeType 'non-breaking' - $breakingNext = Get-NextVersion -currentVersion $CurrentVersion -ChangeType 'breaking' - return ($nonBreakingNext -eq $breakingNext) -} - -# Pure formatter for the per-package menu. Returns a multi-line string ready -# for Write-Host. Returning a string (not host-writing directly) keeps the -# function unit-testable without redirecting Information / Host streams. -# -# Options 3-5 render the *concrete* version transition each choice would -# produce (e.g. "Release as breaking change (0.1.2 -> 0.2.0)"). Get-NextVersion -# is the single source of truth for the version-component math and already -# honours Cargo's 0.x.y semver carve-outs, so the menu always shows the same -# version the release would produce — not a misleading numeric label. -# -# Option 5 (Release as patch) is hidden when it would produce the same numeric -# increment as option 4 (Release as non-breaking change) — see -# Test-IsPatchOptionRedundant. This avoids presenting two indistinguishable -# choices on Cargo 0.x.y packages. -# -# Option 4 (Release as non-breaking change) is hidden when it would produce -# the same numeric increment as option 3 (Release as breaking change) — see -# Test-IsNonBreakingOptionRedundant. This is the case for 0.0.x packages, -# where Cargo treats every version bump as breaking; we append a one-line -# hint explaining why so the user is not left wondering what happened to the -# missing options. -function Format-PackageMenu { - param( - [Parameter(Mandatory = $true)][object]$Finding, - [Parameter(Mandatory = $true)][int]$RemainingCount - ) - - $folder = [string]$Finding.Folder - if ($RemainingCount -gt 0) { - $word = if ($RemainingCount -eq 1) { 'package' } else { 'packages' } - $queueSuffix = " (+$RemainingCount $word queued)" - } else { - $queueSuffix = '' - } - - # Build the version-transition annotations for options 3-5. CurrentVersion - # may be missing on hand-crafted test findings or in unusual non-cargo - # contexts — in that case omit the annotation rather than crash, so the - # menu still presents the choice (the release flow itself will fail loudly - # later if there's truly no version). - $current = [string]$Finding.CurrentVersion - $changeTypeHints = @{} - foreach ($kind in @('breaking', 'non-breaking', 'patch')) { - if ([string]::IsNullOrWhiteSpace($current)) { - $changeTypeHints[$kind] = "($kind)" - } else { - $next = Get-NextVersion -currentVersion $current -ChangeType $kind - $changeTypeHints[$kind] = "($current -> $next)" - } - } - - $hideNonBreaking = Test-IsNonBreakingOptionRedundant -CurrentVersion $current - $hidePatch = $hideNonBreaking -or (Test-IsPatchOptionRedundant -CurrentVersion $current) - - # ChangedFileCount may be 0 (or missing) in -All mode where the planner - # surfaces every published package regardless of on-disk modification - # status. Adapt the header verb and the "View diff" label so the reviewer - # is not misled into expecting changes that aren't there. The "View diff" - # menu option stays in slot 1 in both cases so muscle memory works. - $changeCount = 0 - if ($null -ne $Finding.PSObject.Properties['ChangedFileCount']) { - $rawCount = $Finding.ChangedFileCount - if ($null -ne $rawCount) { - $changeCount = [int]$rawCount - } - } - $hasChanges = $changeCount -gt 0 - - $inReleaseSet = $false - if ($null -ne $Finding.PSObject.Properties['InReleaseSet']) { - $inReleaseSet = [bool]$Finding.InReleaseSet - } - - $headerLine = if ($hasChanges) { - "Detected package with unreleased modifications: $folder$queueSuffix" - } else { - "Reviewing package (no detected changes): $folder$queueSuffix" - } - $viewDiffLabel = if ($hasChanges) { 'View diff' } else { 'View diff (no changes in this package)' } - - $sb = [System.Text.StringBuilder]::new() - [void]$sb.AppendLine('') - [void]$sb.AppendLine($headerLine) - # Show direct in-workspace dependents (packages that import this one - # directly) as a comma-separated list. We deliberately omit transitive - # dependents and full chains: in a workspace with hundreds of packages - # the multi-line chain printout was overwhelming and rarely told the - # reviewer anything they could act on. Direct dependents are what - # cascade-toward-dependents pivots on, so they remain the most relevant - # signal at decision time. Derived from WorkspaceDependencyChains so the - # set is stable and release-set-independent (see chain construction in - # Get-InWorkspaceDependencyChains: each chain is [root, ..., target], - # so the direct dependent of the target is the second-to-last element). - $chains = @($Finding.WorkspaceDependencyChains) - $directDependents = [System.Collections.Generic.SortedSet[string]]::new( - [System.StringComparer]::Ordinal) - foreach ($chain in $chains) { - $arr = @($chain) - if ($arr.Length -lt 2) { continue } - [void]$directDependents.Add($arr[$arr.Length - 2]) - } - if ($directDependents.Count -gt 0) { - $dependentLabel = if ($directDependents.Count -eq 1) { 'Direct dependent' } else { 'Direct dependents' } - [void]$sb.AppendLine(" $($dependentLabel) in this workspace: $($directDependents -join ', ')") - } else { - [void]$sb.AppendLine(' No in-workspace dependents') - } - [void]$sb.AppendLine('') - [void]$sb.AppendLine(" 1. $viewDiffLabel") - if ($inReleaseSet) { - $plannedCurrentVersion = if ($null -ne $Finding.PSObject.Properties['PlannedCurrentVersion']) { - [string]$Finding.PlannedCurrentVersion - } else { - '' - } - $plannedChangeType = if ($null -ne $Finding.PSObject.Properties['EffectiveChangeType']) { - [string]$Finding.EffectiveChangeType - } else { - '' - } - $plannedTargetVersion = if ($null -ne $Finding.PSObject.Properties['EffectiveTargetVersion']) { - [string]$Finding.EffectiveTargetVersion - } else { - '' - } - $plannedHint = if (-not [string]::IsNullOrWhiteSpace($plannedChangeType) -and - -not [string]::IsNullOrWhiteSpace($plannedCurrentVersion) -and - -not [string]::IsNullOrWhiteSpace($plannedTargetVersion)) { - " ($($plannedChangeType): $plannedCurrentVersion -> $plannedTargetVersion)" - } else { - '' - } - [void]$sb.AppendLine(" 2. Keep the release level already in the plan$plannedHint") - } else { - [void]$sb.AppendLine(' 2. No material changes - release only if another package requires it') - } - [void]$sb.AppendLine(" 3. Release as breaking change $($changeTypeHints['breaking'])") - if (-not $hideNonBreaking) { - [void]$sb.AppendLine(" 4. Release as non-breaking change $($changeTypeHints['non-breaking'])") - } - if (-not $hidePatch) { - [void]$sb.AppendLine(" 5. Release as patch $($changeTypeHints['patch'])") - } - if ($hideNonBreaking) { - [void]$sb.AppendLine('') - # Single-quoted to preserve the literal backticks around `0.0.` verbatim - # (backticks are PowerShell's escape character in double-quoted strings). - [void]$sb.AppendLine(' Note: all releases are considered breaking changes for package versions starting with `0.0.`') - } - return $sb.ToString() -} - -# Writes the menu via Write-Host. Side-effect wrapper around Format-PackageMenu -# so the pure formatter stays test-friendly. -function Show-PackageMenu { - param( - [Parameter(Mandatory = $true)][object]$Finding, - [Parameter(Mandatory = $true)][int]$RemainingCount - ) - Write-Host (Format-PackageMenu -Finding $Finding -RemainingCount $RemainingCount) -} - -# Builds the diff text for a single package, anchored at its last release -# baseline (Get-PackageLastReleaseBaseline). When no baseline is found (e.g. -# a never-released package), falls back to `git diff HEAD` and prefixes the -# diff with a warning header so the reader knows the anchor is not a true -# prior release. Untracked files are appended as plain content blocks -# (git diff itself does not include untracked content). -function Get-PackageDiffText { - param( - [Parameter(Mandatory = $true)][string]$RepoRoot, - [Parameter(Mandatory = $true)][string]$Folder - ) - - $sb = [System.Text.StringBuilder]::new() - $relRoot = "crates/$Folder" - - $baseline = Get-PackageLastReleaseBaseline -RepoRoot $RepoRoot -PackageFolder $Folder - if ([string]::IsNullOrWhiteSpace($baseline)) { - [void]$sb.AppendLine("# Diff of '$Folder' (no prior version/publish baseline found - showing working tree vs HEAD)") - [void]$sb.AppendLine('') - $diff = Invoke-Git -Arguments @('diff', 'HEAD', '--', $relRoot) -RepoRoot $RepoRoot -AllowFailure - } else { - [void]$sb.AppendLine("# Diff of '$Folder' since $baseline") - [void]$sb.AppendLine('') - $diff = Invoke-Git -Arguments @('diff', $baseline, '--', $relRoot) -RepoRoot $RepoRoot -AllowFailure - } - - if ($null -ne $diff) { - foreach ($line in @($diff)) { - [void]$sb.AppendLine($line.ToString()) - } - } - - $untracked = Invoke-Git -Arguments @('ls-files', '--others', '--exclude-standard', '--', $relRoot) -RepoRoot $RepoRoot -AllowFailure - if ($null -ne $untracked) { - foreach ($line in @($untracked)) { - $relPath = $line.ToString().Trim().Replace('\', '/') - if ([string]::IsNullOrEmpty($relPath)) { continue } - $absPath = Join-Path $RepoRoot $relPath - [void]$sb.AppendLine('') - [void]$sb.AppendLine("===== UNTRACKED FILE: $relPath =====") - if (Test-Path -LiteralPath $absPath) { - try { - $content = Get-Content -LiteralPath $absPath -Raw -ErrorAction Stop - if ($null -ne $content) { [void]$sb.Append($content) } - if ($null -eq $content -or $content.Length -eq 0 -or -not $content.EndsWith("`n")) { - [void]$sb.AppendLine('') - } - } catch { - [void]$sb.AppendLine("") - } - } else { - [void]$sb.AppendLine('') - } - [void]$sb.AppendLine('===== END UNTRACKED FILE =====') - } - } - - return $sb.ToString() -} - -# Writes the given diff text to a uniquely-named file under the OS temp -# directory (or -Directory, for tests) and returns the resulting path. The -# extension defaults to .txt for safe handling by arbitrary text editors; -# pass -Extension '.diff' when the file will be opened in an editor that -# recognises the diff syntax by extension (e.g. VS Code). -function Save-PackageDiffToTempFile { - param( - [Parameter(Mandatory = $true)][string]$Folder, - [Parameter(Mandatory = $true)][AllowEmptyString()][string]$DiffText, - [string]$Directory, - [string]$Extension = '.txt' - ) - - if (-not $Directory) { $Directory = [System.IO.Path]::GetTempPath() } - if (-not (Test-Path -LiteralPath $Directory)) { - New-Item -ItemType Directory -Path $Directory -Force | Out-Null - } - if (-not $Extension.StartsWith('.')) { $Extension = '.' + $Extension } - - $safeFolder = ($Folder -replace '[^A-Za-z0-9._-]', '_') - $fileName = "oxi-pkg-diff-$safeFolder-$([guid]::NewGuid().ToString('N'))$Extension" - $fullPath = Join-Path $Directory $fileName - Set-Content -LiteralPath $fullPath -Value $DiffText -NoNewline - return $fullPath -} - -# Picks the editor used to render the package diff. Prefers VS Code -# (`code`, then `code-insiders`) because VS Code provides diff syntax -# highlighting out of the box for `.diff` files. Falls back to whatever -# the OS associates with the chosen file extension (handled by -# Open-PathWithPreferredEditor) and to `.txt` so plain text editors can -# always open the file without a "no application registered" error. -# -# Returns @{ Kind = 'code' | 'code-insiders' | 'system'; FileExtension = '.diff' | '.txt' } -function Get-PreferredEditor { - foreach ($cmd in @('code', 'code-insiders')) { - if (Get-Command $cmd -ErrorAction SilentlyContinue) { - return [pscustomobject]@{ - Kind = $cmd - FileExtension = '.diff' - } - } - } - return [pscustomobject]@{ - Kind = 'system' - FileExtension = '.txt' - } -} - -# Opens a path with the preferred editor (see Get-PreferredEditor). When -# `-Editor` is omitted the preferred editor is resolved on the fly. -# Non-blocking; never throws — a failure (no VS Code, no association, -# missing system opener) degrades to a Write-Warning so the calling -# release flow continues. -# -# Platform-aware system-default dispatch is needed because PowerShell -# Core's Start-Process expects an executable on non-Windows platforms, -# not a document path. -function Open-PathWithPreferredEditor { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $false)][object]$Editor - ) - - if ($null -eq $Editor) { $Editor = Get-PreferredEditor } - - try { - if ($Editor.Kind -eq 'code') { - & code $Path - if ($LASTEXITCODE -ne 0) { throw "code exited with code $LASTEXITCODE" } - return - } - if ($Editor.Kind -eq 'code-insiders') { - & code-insiders $Path - if ($LASTEXITCODE -ne 0) { throw "code-insiders exited with code $LASTEXITCODE" } - return - } - - # System default dispatch. - $onWindows = $false - $platformVar = Get-Variable -Name IsWindows -Scope Global -ErrorAction SilentlyContinue - if ($null -eq $platformVar) { - $onWindows = $true - } else { - $onWindows = [bool]$platformVar.Value - } - - if ($onWindows) { - Start-Process -FilePath $Path -ErrorAction Stop | Out-Null - return - } - - if ($IsMacOS) { - & open $Path - if ($LASTEXITCODE -ne 0) { throw "open exited with code $LASTEXITCODE" } - return - } - - $xdg = Get-Command xdg-open -ErrorAction SilentlyContinue - if ($xdg) { - & xdg-open $Path - if ($LASTEXITCODE -ne 0) { throw "xdg-open exited with code $LASTEXITCODE" } - return - } - - $gio = Get-Command gio -ErrorAction SilentlyContinue - if ($gio) { - & gio open $Path - if ($LASTEXITCODE -ne 0) { throw "gio open exited with code $LASTEXITCODE" } - return - } - - throw 'No system file-opener found (tried xdg-open, gio).' - } catch { - Write-Warning "Could not open '$Path' with the preferred editor ($($Editor.Kind)): $_" - } -} - -# Renders the package's diff to a temp file, prints the path, and tries to -# open it with the preferred editor (VS Code if available, otherwise the -# OS default opener). The temp file is tracked in -# $script:TempPackageDiffPaths so Invoke-PlanReview can clean up. -function Show-PackageDiff { - param( - [Parameter(Mandatory = $true)][string]$RepoRoot, - [Parameter(Mandatory = $true)][string]$Folder - ) - - $diffText = Get-PackageDiffText -RepoRoot $RepoRoot -Folder $Folder - $editor = Get-PreferredEditor - $tempPath = Save-PackageDiffToTempFile -Folder $Folder -DiffText $diffText -Extension $editor.FileExtension - - if ($null -eq $script:TempPackageDiffPaths) { - $script:TempPackageDiffPaths = [System.Collections.Generic.List[string]]::new() - } - [void]$script:TempPackageDiffPaths.Add($tempPath) - - Write-Host '' - Write-Host "Diff written to: $tempPath" -ForegroundColor Cyan - Open-PathWithPreferredEditor -Path $tempPath -Editor $editor -} - -# Renders the menu for a single finding and runs the input-validation loop. -# Choice 1 (View diff) shows the diff and re-prompts WITHOUT re-rendering -# the menu (the options are still visible higher in the scrollback); choices -# 2..N resolve to a release action. Empty input silently re-prompts (no -# warning), anything else complains then re-prompts. Returns @{ Action = -# 'ignore' | 'breaking' | 'non-breaking' | 'patch' }. -# -# When option 5 is suppressed (because it would be numerically identical to -# option 4 — see Test-IsPatchOptionRedundant), the prompt range tightens to -# [1-4] and "5" is treated as an invalid choice. When option 4 is also -# suppressed (because on 0.0.x packages every release is breaking — see -# Test-IsNonBreakingOptionRedundant), the prompt range tightens to [1-3] -# and both "4" and "5" are treated as invalid. This keeps the prompt -# honest with what the menu shows. -function Get-PackageReleaseDecision { - param( - [Parameter(Mandatory = $true)][object]$Finding, - [Parameter(Mandatory = $true)][int]$RemainingCount, - [Parameter(Mandatory = $true)][string]$RepoRoot - ) - - $current = [string]$Finding.CurrentVersion - $hideNonBreaking = Test-IsNonBreakingOptionRedundant -CurrentVersion $current - $hidePatch = $hideNonBreaking -or (Test-IsPatchOptionRedundant -CurrentVersion $current) - $maxChoice = if ($hideNonBreaking) { 3 } elseif ($hidePatch) { 4 } else { 5 } - - Show-PackageMenu -Finding $Finding -RemainingCount $RemainingCount - while ($true) { - $raw = Read-Host "Choose option for '$($Finding.Folder)' [1-$maxChoice]" - $choice = if ($null -eq $raw) { '' } else { $raw.Trim() } - - if ($choice -eq '') { continue } - if ($choice -eq '1') { - Show-PackageDiff -RepoRoot $RepoRoot -Folder $Finding.Folder - continue - } - if ($choice -eq '2') { return @{ Action = 'ignore' } } - if ($choice -eq '3') { return @{ Action = 'breaking' } } - if ($choice -eq '4' -and -not $hideNonBreaking) { return @{ Action = 'non-breaking' } } - if ($choice -eq '5' -and -not $hidePatch) { return @{ Action = 'patch' } } - - Write-Host "Invalid choice '$choice'. Enter a number from 1 to $maxChoice." -ForegroundColor Yellow - } -} - - -# Wrapper around the post-release workspace consistency check. Extracted to a -# function so tests can mock it (the real call requires cargo + a fully synced -# workspace, which is impractical inside Pester scenarios). -function Invoke-WorkspaceCheck { - param([string]$RepoRoot) - - Write-Host "" - Write-Host "🔍 Running workspace cargo check..." -ForegroundColor Cyan - - Push-Location $RepoRoot - try { - cargo check --workspace --quiet | Write-Host - if ($LASTEXITCODE -ne 0) { - Write-Error "Workspace 'cargo check' failed after version updates. Please verify the changes." -ErrorAction Stop - } - } finally { - Pop-Location - } -} - -# --- BUNDLED-INPUT RELEASE FLOW --- -# -# The bundled-input flow takes the entire release plan up front (via -# release-packages.ps1's -Packages parameter). The user reviews and decides -# every release in one transaction; the script then writes everything to -# disk atomically. This replaces the iterative single-package model where -# the user had to call the script repeatedly and reconcile on-disk state -# across invocations. -# -# Top-level shape: -# -# Parse-ReleaseTokens (-Packages -> parsed token objects) -# | -# v -# Workspace baseline snapshot (Get-WorkspacePackages, immutable for the run) -# Modified-on-disk snapshot (Get-PackagesWithUnreleasedChanges, immutable) -# | -# v -# Invoke-PlanReview (interactive elevation loop — pure planning, -# no disk writes; loops until findings are -# empty or all reviewed; produces final -# ResolvedReleaseSet hashtable) -# | -# v -# Show-ReleasePlan (display the final plan to the user) -# | -# v -# Invoke-ResolvedRelease (execute the plan in topo order — writes -# Cargo.toml / CHANGELOG.md / README.md for -# every release-set member; produces release -# records for the summary) -# | -# v -# Show-ReleaseSummary + Show-FinalMessageForBundle -# - -# Pre-release interactive elevation review loop. Operates entirely on in-memory -# state (a working list of parse-tokens, a $declined hashset of NON-release-set -# folders the user said "no" to, and a $reviewedCascadeAsIs hashset of -# release-set cascade-source folders the user explicitly said "this cascade -# change type is fine, don't elevate"). A separate $reviewedManualSemver set -# records proc-macro-only packages whose standard decision dialog has completed. -# On each loop: -# -# 1. Re-resolve the release set from the current $userTokens via -# Resolve-ReleaseSet (cheap — operates on the immutable workspace -# baseline, no I/O). In -Mode 'all-changed' with no user tokens yet -# this resolves to an empty set without invoking Resolve-ReleaseSet -# (which throws on empty input). -# 2. Compute findings via Get-UnreleasedModifiedDependencies against the -# fresh release set + immutable modifications snapshot. In -Mode -# 'all-changed' the call passes -IncludeAllModifiedAsRoots so every -# changed published package surfaces from iteration 1. Filter out -# $declined (still-not-in-release-set folders the user declined) and -# $reviewedCascadeAsIs (release-set cascade-source folders the user -# already accepted as-is). -# 3. If empty: review complete — return the resolved release set. -# 4. Prompt the user for the first finding via Get-PackageReleaseDecision. -# On 'ignore' add to the appropriate hashset; on accept -# ('breaking'/'non-breaking'/'patch') append a synthetic -# '@' token to $userTokens and loop. The menu's -# "view diff" option is owned by Get-PackageReleaseDecision and never -# returns control here. -# -# Interactivity is enforced by the entry-point (Invoke-ReleasePackagesMain); -# this function assumes a terminal is available for Read-Host. -# -# Decisions are FINAL. If a previously-declined package is later cascade-pulled -# into the release set, or a previously-reviewed-as-is package has its cascade -# level strengthened by a subsequent acceptance, the user is NOT re-prompted. -# Their "ignore" decision is interpreted as "accept whatever cascade level the -# planner decides; don't bother me about elevation" — a preference invariant -# under cascade-level changes. Cascade reasons for each released package are -# surfaced by Show-ReleasePlan's output for transparency. -# -# Returns: hashtable (folder -> resolved entry) representing the final plan. -# -# Termination: each iteration must change state (adds to or updates $userTokens -# via accept, OR adds to $declined / $reviewedCascadeAsIs / -# $reviewedManualSemver via ignore). Verified by a state-signature comparison at -# the top of each iteration — if two consecutive iterations produce the same -# signature we throw a "no progress" diagnostic rather than infinite-loop. A -# soft runaway cap (10 * published-package count) bounds total prompts as a -# defence-in-depth safety net; the real bound is one prompt per published -# package (the first time it surfaces). -function Invoke-PlanReview { - param( - [Parameter(Mandatory = $true)][string]$RepoRoot, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$ParsedTokens, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline, - # Classifier forwarded to Resolve-ReleaseSet. Defaults to the module-scope - # cargo-semver-checks classifier; tests that mock Resolve-ReleaseSet can - # omit it (the default is never invoked because the mock ignores it). - [Parameter(Mandatory = $false)][scriptblock]$GetRequiredChangeType = $script:DefaultSemverClassifier, - [Parameter(Mandatory = $false)][hashtable]$ModifiedSnapshot, - [Parameter(Mandatory = $false)][ValidateSet('targeted', 'all-changed')][string]$Mode = 'targeted', - [Parameter(Mandatory = $false)][switch]$Force - ) - - # Interactivity is enforced by the entry-point Invoke-ReleasePackagesMain. - # By the time we get here we know we can Read-Host without failing. - - # Working token list, mutable. Each accepted finding appends a new token. - $userTokens = New-Object 'System.Collections.Generic.List[object]' - foreach ($t in $ParsedTokens) { $userTokens.Add($t) } - - $declined = [System.Collections.Generic.HashSet[string]]::new() - # Set of release-set cascade-source folders the user said "keep cascade- - # applied level, don't elevate". Entries are never removed: the decision - # stands even if cascade strengthens the level on a later iteration. - $reviewedCascadeAsIs = [System.Collections.Generic.HashSet[string]]::new() - # Proc-macro-only packages cannot be classified by cargo-semver-checks. - # Every such package that enters the plan is shown in the standard review - # dialog exactly once, including user-source entries and unchanged thin - # proc-macro crates pulled in by an implementation-crate release. - $reviewedManualSemver = [System.Collections.Generic.HashSet[string]]::new() - - # Runaway cap is a defence-in-depth safety net; the real termination - # guarantee comes from the state-signature progress check below. Each - # published package is reviewed at most once (decisions are final), so - # 10x the published count is comfortably above the worst case. - $publishedCount = @(Get-WorkspacePackages -repoRoot $RepoRoot | Where-Object { $_.Published }).Count - if ($publishedCount -lt 1) { $publishedCount = 1 } - $runawayCap = 10 * $publishedCount - - # Save/restore the temp-diff-paths tracking list (used by Show-PackageDiff) - # to match the lifecycle of this review loop. - $prevTempPaths = $script:TempPackageDiffPaths - $script:TempPackageDiffPaths = [System.Collections.Generic.List[string]]::new() - - $resolvedHash = $null - $previousSignature = $null - - try { - for ($iter = 0; $iter -lt $runawayCap; $iter++) { - # State signature: every iteration must mutate at least one of - # {userTokens, declined, reviewedCascadeAsIs, reviewedManualSemver}. - # The current control flow guarantees this — accept appends to or - # updates userTokens; ignore adds to one of the review sets; the - # switch's default arm throws on any unrecognised action; an empty - # queue early-returns. - # The signature check is therefore unreachable in normal - # operation, but kept as defense-in-depth so a future change that - # introduces a state-leak path (e.g. a new `continue`-without- - # mutate branch) aborts with a clear diagnostic instead of - # silently spinning until the runaway cap fires. - $tokenSig = (@($userTokens.ToArray()) | ForEach-Object { $_.RawToken }) -join '|' - $declinedSig = (@($declined) | Sort-Object) -join ',' - $reviewedSig = (@($reviewedCascadeAsIs) | Sort-Object) -join ',' - $manualSig = (@($reviewedManualSemver) | Sort-Object) -join ',' - $signature = "tokens=[$tokenSig];declined=[$declinedSig];reviewed=[$reviewedSig];manual=[$manualSig]" - if ($iter -gt 0 -and $signature -eq $previousSignature) { - throw "Plan review made no progress on iteration $iter (state signature unchanged). This indicates a logic bug; please report. Signature: $signature" - } - $previousSignature = $signature - - # Re-resolve the release set from the current token list. Pure - # in-memory operation; no caching/snapshot invalidation needed. - # In all-changed mode the user may have accepted nothing yet, in - # which case Resolve-ReleaseSet throws on empty input — handle - # that here rather than relaxing the earlier guard, which would - # weaken targeted-mode validation. - if ($Mode -eq 'all-changed' -and $userTokens.Count -eq 0) { - $resolvedHash = @{} - } else { - $resolvedArr = @(Resolve-ReleaseSet -ParsedTokens $userTokens.ToArray() -WorkspaceBaseline $WorkspaceBaseline -GetRequiredChangeType $GetRequiredChangeType -Force:$Force) - $resolvedHash = @{} - foreach ($e in $resolvedArr) { $resolvedHash[$e.Folder] = $e } - } - - # Handoff: a previously-declined or previously-reviewed-as-is folder - # may now have a different cascade story (cascade pulled it into the - # release set, or strengthened its level). Ordinary decisions are - # final, but they cannot suppress a later mandatory proc-macro-chain - # review. Show-ReleasePlan records the cascade reasons. - - Write-Host '' - Write-Host '🔍 Analyzing packages for unreleased modifications...' -ForegroundColor Cyan - - if ($Mode -eq 'all-changed') { - $modifiedFindings = @(Get-UnreleasedModifiedDependencies -RepoRoot $RepoRoot -ResolvedReleaseSet $resolvedHash -ModifiedSnapshot $ModifiedSnapshot -IncludeAllModifiedAsRoots) - } else { - $modifiedFindings = @(Get-UnreleasedModifiedDependencies -RepoRoot $RepoRoot -ResolvedReleaseSet $resolvedHash -ModifiedSnapshot $ModifiedSnapshot) - } - - # Manual SemVer findings take precedence over ordinary - # modification/elevation findings for the same folder. The queue - # includes unchanged proc-macro release-set members and advances to - # direct published consumers one breaking reviewed edge at a time. - $findingsByFolder = [ordered]@{} - $manualFindings = @(Get-ManualSemverReviewFindings ` - -ResolvedReleaseSet $resolvedHash ` - -WorkspaceBaseline $WorkspaceBaseline ` - -ModifiedSnapshot $ModifiedSnapshot ` - -ReviewedManualSemver $reviewedManualSemver) - foreach ($finding in @($manualFindings) + @($modifiedFindings)) { - if (-not $findingsByFolder.Contains($finding.Folder)) { - $findingsByFolder[$finding.Folder] = $finding - } - } - - $queue = @( - $findingsByFolder.Values | Where-Object { - if ($_.RequiresManualSemverReview) { - # A prior ordinary "skip" or "keep cascade level" - # decision did not evaluate the opaque proc-macro - # contract. Only a completed manual review may suppress - # this finding. Choosing "No material changes" in the - # manual dialog counts as a completed review. - return -not $reviewedManualSemver.Contains($_.Folder) - } - return -not $declined.Contains($_.Folder) -and - -not $reviewedCascadeAsIs.Contains($_.Folder) - } - ) - - if ($queue.Count -eq 0) { - Write-Host '' - Write-Host '✅ No further unreleased modifications detected; release plan finalised.' -ForegroundColor Green - Set-ManualSemverReviewAnnotations ` - -ResolvedReleaseSet $resolvedHash ` - -WorkspaceBaseline $WorkspaceBaseline ` - -ReviewedManualSemver $reviewedManualSemver ` - -ModifiedSnapshot $ModifiedSnapshot - return $resolvedHash - } - - $next = $queue[0] - $remaining = $queue.Count - 1 - $decision = Get-PackageReleaseDecision -Finding $next -RemainingCount $remaining -RepoRoot $RepoRoot - $isManualSemverReview = [bool]$next.RequiresManualSemverReview - if ($isManualSemverReview) { - # Every answer completes the manual review. "No material - # changes" means no release now, but a later cascade may still - # add the package at the patch floor without asking again. - [void]$reviewedManualSemver.Add($next.Folder) - } - - if ($decision.Action -eq 'ignore') { - if ($next.InReleaseSet) { - $plannedLevel = $resolvedHash[$next.Folder].EffectiveChangeType - Write-Host " Keeping '$($next.Folder)' at its currently planned $plannedLevel release level." -ForegroundColor DarkGray - if (-not $isManualSemverReview) { - [void]$reviewedCascadeAsIs.Add($next.Folder) - } - } else { - Write-Host " Skipping '$($next.Folder)'; cascade may still pull it into the release plan on a later iteration." -ForegroundColor DarkGray - [void]$declined.Add($next.Folder) - } - continue - } - - # Accept: synthesise a token. The decision action vocabulary - # ('breaking'/'non-breaking'/'patch') maps to the parse-token - # change-spec vocabulary ('breaking'/'nonbreaking'/'patch'). - # - # For both new and elevation cases we craft the parsed-token object - # directly rather than going through Parse-ReleaseTokens. A finding - # can also re-surface a user-source entry; replace its provisional - # token below instead of adding a duplicate. - $changeSpec = switch ($decision.Action) { - 'breaking' { 'breaking' } - 'non-breaking' { 'nonbreaking' } - 'patch' { 'patch' } - default { throw "Internal error: Get-PackageReleaseDecision returned unexpected action '$($decision.Action)'." } - } - $newToken = "$($next.Folder)@$changeSpec" - $newTokenEntry = [pscustomobject]@{ - Name = $next.Folder - RequestedChangeType = $decision.Action - RequestedTargetVersion = $null - RawToken = $newToken - } - - $existingEntry = $resolvedHash[$next.Folder] - if ($null -ne $existingEntry -and $existingEntry.Source -eq 'user') { - # The package was already a user-source token (targeted mode). - # Replace that provisional decision instead of appending a - # duplicate token that Resolve-ReleaseSet would reject. - $folderNorm = $next.Folder.Replace('-', '_') - $cargoNorm = $existingEntry.Name.Replace('-', '_') - $tokenIndex = -1 - for ($i = 0; $i -lt $userTokens.Count; $i++) { - $tokenNorm = $userTokens[$i].Name.Replace('-', '_') - if ($tokenNorm -eq $folderNorm -or $tokenNorm -eq $cargoNorm) { - $tokenIndex = $i - break - } - } - if ($tokenIndex -lt 0) { - throw "Internal error: could not locate the user token for reviewed package '$($next.Folder)'." - } - $userTokens[$tokenIndex] = $newTokenEntry - } else { - $userTokens.Add($newTokenEntry) - } - } - - Write-Warning "Plan review reached its runaway-cap of $runawayCap iterations; aborting further prompts. This is a defence-in-depth safety net — the state-signature check above should have caught any logic loop earlier; if you see this, please report." - # Re-resolve before returning so the final acceptance of the last - # iteration (if any) is reflected in the plan handed back to the - # caller. Without this, callers see the resolved set from the START - # of the final iteration, missing the token just appended. - if ($Mode -eq 'all-changed' -and $userTokens.Count -eq 0) { - $resolvedHash = @{} - } else { - $resolvedArr = @(Resolve-ReleaseSet -ParsedTokens $userTokens.ToArray() -WorkspaceBaseline $WorkspaceBaseline -GetRequiredChangeType $GetRequiredChangeType -Force:$Force) - $resolvedHash = @{} - foreach ($e in $resolvedArr) { $resolvedHash[$e.Folder] = $e } - } - Set-ManualSemverReviewAnnotations ` - -ResolvedReleaseSet $resolvedHash ` - -WorkspaceBaseline $WorkspaceBaseline ` - -ReviewedManualSemver $reviewedManualSemver ` - -ModifiedSnapshot $ModifiedSnapshot - return $resolvedHash - } finally { - foreach ($p in $script:TempPackageDiffPaths) { - try { - if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force -ErrorAction Stop } - } catch { - Write-Warning "Could not delete temp diff file '$p': $_" - } - } - $script:TempPackageDiffPaths = $prevTempPaths - } -} - -# Topological sort of a resolved release set: dependencies first, dependents -# last. Uses Kahn's algorithm against the workspace baseline so the order is -# deterministic and unaffected by hashtable enumeration order. -# -# Folders with no in-set dependencies come first (the "leaves" of the -# release-set sub-DAG). Among equal-rank candidates, ties are broken by -# folder name so output is reproducible across runs. -function Get-TopoOrderedReleaseFolders { - param( - [Parameter(Mandatory = $true)][hashtable]$ResolvedReleaseSet, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline - ) - - if ($ResolvedReleaseSet.Count -eq 0) { return @() } - - $folders = @($ResolvedReleaseSet.Keys) - $byFolder = @{} - $byCargo = @{} - foreach ($pkg in $WorkspaceBaseline) { - $byFolder[$pkg.Folder] = $pkg - $byCargo[$pkg.Name.Replace('-', '_')] = $pkg - } - - # Build adjacency: for each release-set folder, the in-set folders it - # depends on (its deps that are also in the release set). - $inSetDeps = @{} - $inDegree = @{} - foreach ($folder in $folders) { - $pkg = $byFolder[$folder] - $deps = New-Object 'System.Collections.Generic.HashSet[string]' - if ($null -ne $pkg) { - foreach ($depCargo in $pkg.Deps) { - $depPkg = $byCargo[$depCargo] - if ($null -ne $depPkg -and $ResolvedReleaseSet.ContainsKey($depPkg.Folder)) { - [void]$deps.Add($depPkg.Folder) - } - } - } - $inSetDeps[$folder] = $deps - $inDegree[$folder] = $deps.Count - } - - $ready = [System.Collections.Generic.List[string]]::new() - foreach ($f in $folders) { - if ($inDegree[$f] -eq 0) { $ready.Add($f) } - } - - $result = [System.Collections.Generic.List[string]]::new() - while ($ready.Count -gt 0) { - $sortedReady = @($ready | Sort-Object) - $next = $sortedReady[0] - [void]$ready.Remove($next) - $result.Add($next) - - foreach ($f in $folders) { - if ($inSetDeps[$f].Contains($next)) { - $inDegree[$f] = $inDegree[$f] - 1 - if ($inDegree[$f] -eq 0) { $ready.Add($f) } - } - } - } - - if ($result.Count -ne $folders.Count) { - # Cycle in dependencies among release-set members — the workspace - # itself would already be broken; surface it loudly. - throw "Get-TopoOrderedReleaseFolders: dependency cycle detected among release-set members; cannot determine release order." - } - - return $result.ToArray() -} - -# Returns the changelog "Now requires of " bullet reasons for -# a dependent: only the DIRECT workspace deps (normal/build) in this release with -# a changed version, each at its new version. Decoupled from CascadeReasons, -# which attribute a cascade to its root cause — an indirect dependent must not -# claim to require a crate it does not directly depend on (ADO bug 7536096). -function Get-DirectDependencyChangelogReasons { - param( - [Parameter(Mandatory = $true)][object]$Entry, - [Parameter(Mandatory = $true)][hashtable]$ResolvedReleaseSet, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline - ) - - $baselinePkg = $null - foreach ($pkg in $WorkspaceBaseline) { - if ($pkg.Folder -eq $Entry.Folder) { $baselinePkg = $pkg; break } - } - if ($null -eq $baselinePkg -or $null -eq $baselinePkg.Deps) { - return @() - } - - # Baseline .Deps use underscore-normalized cargo names; the resolved set is - # keyed by Folder, so index it by the same normalization to match. - $resolvedByNormName = @{} - foreach ($e in $ResolvedReleaseSet.Values) { - $resolvedByNormName[$e.Name.Replace('-', '_')] = $e - } - - # Section selection follows the per-edge cascade Breaking flags (not the - # dependent's own change type): a crate that is both a breaking user target - # and a non-breaking cascade dependent must keep its bullet under Maintenance. - $sectionBreaking = $false - if ($null -ne $Entry.CascadeReasons) { - foreach ($cr in $Entry.CascadeReasons) { - if ([bool]$cr.Breaking) { $sectionBreaking = $true; break } - } - } - - $reasons = New-Object 'System.Collections.Generic.List[object]' - foreach ($depNorm in $baselinePkg.Deps) { - $depEntry = $resolvedByNormName[$depNorm] - if ($null -eq $depEntry) { continue } - if ($depEntry.CurrentVersion -eq $depEntry.EffectiveTargetVersion) { continue } - $reasons.Add([pscustomobject]@{ - Target = $depEntry.Name - Version = $depEntry.EffectiveTargetVersion - Breaking = $sectionBreaking - }) - } - - return $reasons.ToArray() -} - -# Executes a finalised release plan. For each release-set entry, in topo order -# (dependencies first), writes Cargo.toml + workspace Cargo.toml + CHANGELOG + -# README. No cascade logic, no user prompts — every release decision was -# already made in Invoke-PlanReview. -# -# Returns release records: @(@{Package; OldVersion; NewVersion}, ...) in -# release order. -# -# The function is plan-driven: it never re-reads the on-disk Cargo.toml to -# determine the next version. The plan's EffectiveTargetVersion is the source -# of truth. This makes Invoke-ResolvedRelease provably independent of any -# mid-execution disk state observation. -function Invoke-ResolvedRelease { - param( - [Parameter(Mandatory = $true)][string]$RepoRoot, - [Parameter(Mandatory = $true)][string]$RootCargoToml, - [Parameter(Mandatory = $false)][string]$PrBaseUrl, - [Parameter(Mandatory = $true)][hashtable]$ResolvedReleaseSet, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline - ) - - if ($ResolvedReleaseSet.Count -eq 0) { return @() } - - $orderedFolders = Get-TopoOrderedReleaseFolders -ResolvedReleaseSet $ResolvedReleaseSet -WorkspaceBaseline $WorkspaceBaseline - - $records = New-Object 'System.Collections.Generic.List[object]' - - foreach ($folder in $orderedFolders) { - $entry = $ResolvedReleaseSet[$folder] - $packageFolder = Join-Path $RepoRoot 'crates' $folder - $packageCargoToml = Join-Path $packageFolder 'Cargo.toml' - $changelogFile = Join-Path $packageFolder 'CHANGELOG.md' - - $oldVersion = $entry.CurrentVersion - $newVersion = $entry.EffectiveTargetVersion - - Write-Host '' - $sourceLabel = if ($entry.Source -eq 'user') { 'user-requested' } else { 'cascade-from-dependency' } - Write-Host "🚀 Releasing '$folder' ($sourceLabel): $oldVersion -> $newVersion" -ForegroundColor Cyan - - # The plan's EffectiveTargetVersion is taken verbatim — this keeps - # the executor plan-driven. - $written = Update-PackageVersion -packageName $entry.Name -version $newVersion ` - -packageCargoToml $packageCargoToml -rootCargoToml $RootCargoToml - if ($null -eq $written) { - Write-Error "Failed to update version for package '$folder'." -ErrorAction Stop - } - - $cascadeReasons = Get-DirectDependencyChangelogReasons -Entry $entry ` - -ResolvedReleaseSet $ResolvedReleaseSet -WorkspaceBaseline $WorkspaceBaseline - if ($null -ne $cascadeReasons -and $cascadeReasons.Count -eq 0) { - $cascadeReasons = $null - } - Write-Changelog -packageName $entry.Name -newVersion $newVersion -packageFolder $packageFolder ` - -changelogFile $changelogFile -prBaseUrl $PrBaseUrl -cascadeReasons $cascadeReasons - - Update-Readme -packageName $entry.Name -packageFolder $packageFolder - - $records.Add([pscustomobject]@{ - Package = $folder - OldVersion = $oldVersion - NewVersion = $newVersion - }) - } - - # The on-disk workspace metadata is now stale (we just rewrote Cargo.tomls); - # later operations that rely on cargo metadata (e.g. Invoke-WorkspaceCheck) - # must observe the new state. - Invalidate-WorkspaceMetadataCache - - return $records.ToArray() -} - -# Pretty-prints the resolved release plan before execution so the user can -# eyeball the final state. Lists user-source members first (in token order), -# then cascade-source members (sorted by folder for determinism). For each -# entry, shows the version transition, the source, the effective change -# type, and any cascade reasons. AutoUpgraded user-source entries are -# flagged so the user notices when cascade strengthened their request. -function Show-ReleasePlan { - param( - [Parameter(Mandatory = $true)][hashtable]$ResolvedReleaseSet - ) - - if ($ResolvedReleaseSet.Count -eq 0) { - Write-Host '' - Write-Host '📋 Release plan: (empty)' -ForegroundColor Yellow - return - } - - $userEntries = @($ResolvedReleaseSet.Values | Where-Object { $_.Source -eq 'user' }) - $cascadeEntries = @($ResolvedReleaseSet.Values | Where-Object { $_.Source -eq 'cascade' } | Sort-Object -Property Folder) - - $total = $ResolvedReleaseSet.Count - $packageNoun = if ($total -eq 1) { 'package' } else { 'packages' } - - Write-Host '' - Write-Host "📋 Final release plan ($total $packageNoun):" -ForegroundColor Cyan - - foreach ($entry in $userEntries) { - $tag = if ($entry.PinHonoredAgainstCascade) { - "user-requested ($($entry.EffectiveChangeType); -Force: pin honored over cascade)" - } elseif ($entry.AutoUpgraded) { - "user-requested (auto-upgraded by cascade to $($entry.EffectiveChangeType))" - } else { - "user-requested ($($entry.EffectiveChangeType))" - } - $color = if ($entry.PinHonoredAgainstCascade) { 'Yellow' } else { 'Green' } - Write-Host " • $($entry.Folder): $($entry.CurrentVersion) -> $($entry.EffectiveTargetVersion) [$tag]" -ForegroundColor $color - if ($entry.IsProcMacroOnly) { - Write-Host ' SemVer classification: manual proc-macro review (cargo-semver-checks not run)' -ForegroundColor Yellow - } elseif ($entry.ManualSemverReviewCompleted) { - $sources = @($entry.ManualSemverReviewSources) -join ', ' - Write-Host " SemVer classification: cargo-semver-checks plus manual review of breaking proc-macro dependency chain ($sources)" -ForegroundColor Yellow - } - if ($null -ne $entry.CascadeReasons -and $entry.CascadeReasons.Count -gt 0) { - $names = ($entry.CascadeReasons | ForEach-Object { $_.Target } | Sort-Object -Unique) -join ', ' - $reasonLabel = if ($entry.PinHonoredAgainstCascade) { 'cascade required upgrade from' } else { 'strengthened by cascade from' } - Write-Host " $($reasonLabel): $names" -ForegroundColor DarkGray - } - } - - foreach ($entry in $cascadeEntries) { - Write-Host " • $($entry.Folder): $($entry.CurrentVersion) -> $($entry.EffectiveTargetVersion) [cascade ($($entry.EffectiveChangeType))]" -ForegroundColor DarkCyan - if ($entry.IsProcMacroOnly) { - Write-Host ' SemVer classification: manual proc-macro review (cargo-semver-checks not run)' -ForegroundColor Yellow - } elseif ($entry.ManualSemverReviewCompleted) { - $sources = @($entry.ManualSemverReviewSources) -join ', ' - Write-Host " SemVer classification: cargo-semver-checks plus manual review of breaking proc-macro dependency chain ($sources)" -ForegroundColor Yellow - } - $names = ($entry.CascadeReasons | ForEach-Object { $_.Target } | Sort-Object -Unique) -join ', ' - Write-Host " cascaded from: $names" -ForegroundColor DarkGray - } - - # Footer: explain how cascade interacts with user input. Always printed so - # reviewers don't have to remember the contract; the conditional lines - # only fire when the corresponding situation applies (at least one user - # entry was auto-upgraded, or at least one was pinned over cascade via - # -Force). - Write-Host '' - Write-Host 'Note: user-provided change types may be automatically upgraded if cascade logic deems it necessary (e.g. non-breaking -> breaking).' -ForegroundColor DarkGray - $autoUpgraded = @($userEntries | Where-Object { $_.AutoUpgraded }) - if ($autoUpgraded.Count -gt 0) { - $autoUpgradedLine = if ($autoUpgraded.Count -eq 1) { - "Item above tagged 'auto-upgraded by cascade' was upgraded from the user-requested change type." - } else { - "Items above tagged 'auto-upgraded by cascade' were upgraded from the user-requested change type." - } - Write-Host $autoUpgradedLine -ForegroundColor DarkGray - } - $forcedPins = @($userEntries | Where-Object { $_.PinHonoredAgainstCascade }) - if ($forcedPins.Count -gt 0) { - $forcedPinsLine = if ($forcedPins.Count -eq 1) { - "Item above tagged '-Force: pin honored over cascade' kept its explicit version pin even though cascade required a higher version — consumers may break." - } else { - "Items above tagged '-Force: pin honored over cascade' kept their explicit version pin even though cascade required a higher version — consumers may break." - } - Write-Host $forcedPinsLine -ForegroundColor Yellow - } - Write-Host 'If an explicit version number is specified in the release-spec but cascade logic requires a higher version number, the release plan is rejected (use -Force to override).' -ForegroundColor DarkGray -} - -# Prints the "Success! Next steps" block after a bundled release. Picks the -# alphabetically-first user-source folder as the "primary" for the -# conventional-commits scope (best-effort heuristic; the multi-package wording -# supersedes it when more than one package is released). -function Show-FinalMessageForBundle { - param( - [Parameter(Mandatory = $true)][array]$Releases, - [Parameter(Mandatory = $true)][hashtable]$ResolvedReleaseSet - ) - - if ($Releases.Count -eq 0) { - Write-Host '---' -ForegroundColor Green - Write-Host 'ℹ️ No releases produced; nothing to commit.' -ForegroundColor Green - Write-Host '---' -ForegroundColor Green - return - } - - # Identify the primary by taking the first user-source folder in the plan - # (alphabetic order; matches the topo-sort tie-breaker for stability). - $userFolders = @($ResolvedReleaseSet.Values | Where-Object { $_.Source -eq 'user' } | ForEach-Object { $_.Folder } | Sort-Object) - $primaryFolder = if ($userFolders.Count -gt 0) { $userFolders[0] } else { $Releases[0].Package } - $primary = $Releases | Where-Object { $_.Package -eq $primaryFolder } | Select-Object -First 1 - if ($null -eq $primary) { $primary = $Releases[0] } - - $primaryName = $primary.Package - $primaryVersion = $primary.NewVersion - - $extraCount = @($Releases).Count - 1 - if ($extraCount -le 0) { - $commitMessage = "feat($primaryName): release v$primaryVersion" - } else { - $extraNoun = if ($extraCount -eq 1) { 'additional package' } else { 'additional packages' } - $commitMessage = "feat: release $primaryName v$primaryVersion and $extraCount $extraNoun" - } - - Write-Host '---' -ForegroundColor Green - Write-Host '🎉 Success! Next steps:' -ForegroundColor Green - Write-Host '1. Review the changes in the updated files.' -ForegroundColor Green - Write-Host '2. Commit the changes and push the changes:' -ForegroundColor Green - Write-Host ' git add .' -ForegroundColor DarkGray - Write-Host " git commit -m `"$commitMessage`"" -ForegroundColor DarkGray - Write-Host ' git push' -ForegroundColor DarkGray - Write-Host '3. Once the commit is merged to main, automation will tag the commit and release to crates.io' -ForegroundColor Green - Write-Host '---' -ForegroundColor Green -} - -# Top-level entry point for the bundled-input release flow. Routes the three -# user-facing modes ('targeted' / 'changed' / 'all') through one shared -# pipeline: pre-flight checks, plan resolution / cascade / elevation review, -# plan display, atomic execution, post-execution workspace `cargo check`, and -# final summary message. -# -# Every mode is intended for interactive use — the elevation review prompts -# the user even in targeted mode if modified-but-unreleased dependencies of -# the requested packages are detected. The script refuses to run when stdin -# is not a terminal, regardless of mode. -# -# Targeted mode is the only mode that accepts a non-empty $Packages list. -# Changed and All modes discover their own targets: -# -# - Changed surfaces every published workspace package with unreleased -# modifications (changes newer than its last `version =` / -# `publish =` commit). -# - All surfaces every published workspace package, regardless of whether -# the on-disk content has been modified. The change-detection scan still -# runs (for ChangedFileCount accuracy in the menu) but its result is -# augmented so the predicate inside Get-UnreleasedModifiedDependencies -# accepts every published package as eligible. -# -# Returns the array of release records (so Pester scenarios can assert on -# them). Input-validation / pre-flight / execution errors are surfaced as -# terminating errors (throw) so callers can catch them. The thin CLI shell -# (release-packages.ps1) converts a throw into `exit 1`; test harnesses catch -# the exception directly. This keeps the entry point testable in-process — a -# bare `Exit` would tear down the whole test runspace. -function Invoke-ReleasePackagesMain { - [CmdletBinding()] - param( - [Parameter()] - [ValidateSet('targeted', 'changed', 'all')] - [string]$Mode = 'targeted', - - [Parameter()] - [AllowNull()] - [AllowEmptyCollection()] - [string[]]$Packages = @(), - - [Parameter()] - [switch]$Force - ) - - # 1. PRE-FLIGHT - if (-not (Test-CommandExists -command 'git')) { - throw 'Git is not installed or not found in your PATH.' - } - - # cargo-semver-checks decides every change type in the plan (against each - # crate's previous version-bump commit). It is a hard dependency — there is no - # heuristic fallback — so fail fast with an actionable message if missing. - if (-not (Test-CommandExists -command 'cargo-semver-checks')) { - throw "cargo-semver-checks is not installed or not found in your PATH. Install the version pinned in constants.env (CARGO_SEMVER_CHECKS_VERSION) with 'cargo install cargo-semver-checks --version --locked'. It is required to classify releases against their previous version-bump commit." - } - - $repoRoot = Get-Location - if (-not (Test-Path (Join-Path $repoRoot '.git'))) { - throw 'This script must be run from the root of a Git repository.' - } - $rootCargoToml = Join-Path $repoRoot 'Cargo.toml' - if (-not (Test-Path $rootCargoToml)) { - throw "Could not find root Cargo.toml at '$rootCargoToml'." - } - - # Every mode is interactive: the elevation review can prompt even in - # targeted mode when a requested release has modified-but-unreleased - # dependencies. Bail out early with a clear error if stdin is not a - # terminal, rather than failing deep inside Read-Host. - if (-not (Test-InteractiveSession)) { - throw 'release-packages.ps1 must be run from an interactive terminal — every mode may prompt the user for elevation review of modified-but-unreleased dependencies.' - } - - # 2. MODE / INPUT VALIDATION + TOKEN PARSE - $hasTokens = ($null -ne $Packages) -and ($Packages.Count -gt 0) - if ($Mode -ne 'targeted' -and $Force) { - throw "release-packages.ps1 -Force is only valid with -Packages (targeted mode). The -Changed and -All modes only accept change-type answers (breaking / non-breaking / patch) and never explicit version pins, so the pin-vs-cascade rejection that -Force overrides cannot fire." - } - if ($Mode -eq 'targeted') { - if (-not $hasTokens) { - throw 'release-packages.ps1 -Packages requires at least one ''@'' token. Use -Changed or -All for a guided walk instead.' - } - $parsedTokens = Parse-ReleaseTokens -Tokens $Packages - } else { - if ($hasTokens) { - throw "release-packages.ps1 -$Mode does not accept -Packages tokens; the planner discovers targets for you." - } - $parsedTokens = @() - } - - # 3. DETERMINE GITHUB REPO URL - $prBaseUrl = $null - $remoteUrl = Invoke-Git -Arguments @('remote', 'get-url', 'origin') -RepoRoot $repoRoot.Path -AllowFailure - if ($remoteUrl -and $remoteUrl -match $script:GitHubRepoRegex) { - $repoIdentifier = $matches[1] -replace '\.git$', '' - $prBaseUrl = "https://github.com/$repoIdentifier/pull" - } else { - Write-Warning "Could not determine GitHub repository from remote 'origin'. Links will not be generated." - } - - # 4. SNAPSHOT WORKSPACE + MODIFICATIONS (immutable for the run) - $workspaceBaseline = @(Get-WorkspacePackages -repoRoot $repoRoot.Path) - $modifiedSnapshot = Get-PackagesWithUnreleasedChanges -RepoRoot $repoRoot.Path - - # 4a. ALL-MODE SNAPSHOT AUGMENTATION (private to this entry-point). - # Get-UnreleasedModifiedDependencies's surfacing predicate filters on - # $modifiedMap.ContainsKey($folder). To make every publishable package - # eligible without threading a new mode flag through the predicate, we - # synthesise stub entries with ChangedFileCount = 0 for each published - # package not already represented. The synthesised snapshot must NOT - # escape this function — its zero-count entries would be misleading in - # PR-comment / dep-scan contexts that consume "really modified" data. - if ($Mode -eq 'all') { - foreach ($pkg in $workspaceBaseline) { - if ($pkg.Published -and -not $modifiedSnapshot.ContainsKey($pkg.Folder)) { - $modifiedSnapshot[$pkg.Folder] = 0 - } - } - } - - # 4b. CHANGED-MODE EARLY EXIT — saves the user from an empty prompt loop. - if ($Mode -eq 'changed' -and $modifiedSnapshot.Count -eq 0) { - Write-Host '' - Write-Host '✅ No workspace packages have unreleased modifications. Nothing to release.' -ForegroundColor Green - return ,@() - } - - # 5. PRE-RELEASE REVIEW (interactive loop; no disk writes). - # 'changed' and 'all' both map to Invoke-PlanReview's 'all-changed' mode: - # the planner adds every snapshot entry as a BFS root, surfacing them - # one-by-one for a per-package decision. Acceptances become tokens inside - # the loop and feed Resolve-ReleaseSet on the next iteration just like - # the targeted flow. - $planReviewMode = if ($Mode -eq 'targeted') { 'targeted' } else { 'all-changed' } - - # Classifier passed to the planner: decides each crate's OWN change-type - # floor (user-source and cascade alike) from its real API diff vs its - # previous version-bump commit in git history — no registry, no fallback. - # This is only half the verdict: Resolve-ReleaseSet's exposed-dependency - # cascade can raise an entry above this floor, using - # allowed_external_types to decide exposure (a dependency version bump - # changes type identity without changing any rustdoc, so no API diff can - # surface it). $script:DefaultSemverClassifier is a module-scope - # scriptblock (defined below Resolve-ReleaseSet) so it resolves - # Get-CrateRequiredChangeType and $script:ReleaseRepoRoot in the module - # session state; that also lets the test suites Mock Get-CrateRequiredChangeType. - # Get-CrateRequiredChangeType memoises per crate so the interactive review loop - # re-resolves cheaply. - $script:ReleaseRepoRoot = $repoRoot.Path - - # The classifier and Invoke-PlanReview surface planning errors (e.g. a pin - # below the semver-required version) as terminating errors. Let them - # propagate to the caller (the CLI shell turns them into `exit 1`; tests - # catch them) rather than swallowing and re-emitting, which would tear down - # a test runspace via Exit. - $resolvedHash = Invoke-PlanReview -RepoRoot $repoRoot.Path ` - -ParsedTokens $parsedTokens -WorkspaceBaseline $workspaceBaseline ` - -GetRequiredChangeType $script:DefaultSemverClassifier ` - -ModifiedSnapshot $modifiedSnapshot -Mode $planReviewMode -Force:$Force - - # 6. EARLY EXIT IF GUIDED USER IGNORED EVERYTHING — skip Show-ReleasePlan - # / Invoke-ResolvedRelease / Invoke-WorkspaceCheck. They handle empty - # input gracefully but we'd waste a `cargo check` run and the user - # already knows nothing will happen. Cannot apply to targeted mode - # because targeted requires at least one token. - if ($Mode -ne 'targeted' -and ($null -eq $resolvedHash -or $resolvedHash.Count -eq 0)) { - Write-Host '' - Write-Host '✅ No packages selected for release.' -ForegroundColor Green - return ,@() - } - - # 7. SHOW PLAN - Show-ReleasePlan -ResolvedReleaseSet $resolvedHash - - # 8. EXECUTE PLAN (atomic — all writes happen here) - try { - $releases = @(Invoke-ResolvedRelease -RepoRoot $repoRoot.Path -RootCargoToml $rootCargoToml ` - -PrBaseUrl $prBaseUrl -ResolvedReleaseSet $resolvedHash -WorkspaceBaseline $workspaceBaseline) - } catch { - throw "Release execution failed: $_" - } - - Invoke-WorkspaceCheck -RepoRoot $repoRoot.Path - - Show-ReleaseSummary -releases $releases - Show-FinalMessageForBundle -Releases $releases -ResolvedReleaseSet $resolvedHash - - return ,$releases -} diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index 9b150cd5e..b479d31dc 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -125,21 +125,6 @@ function Get-FileLineEnding { return "`n" } -# --- VERSION HELPERS --- - -function Test-ValidPackageName { - param([string]$packageName) - return $packageName -match '^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$' -and $packageName.Length -le 64 -} - -function Test-ValidVersion { - param([string]$version) - if ([string]::IsNullOrEmpty($version)) { - return $true - } - return $script:SemanticVersionRegex.IsMatch($version) -} - # Strict SemVer 2.0 splitter — validates with $script:SemanticVersionRegex and # returns a hashtable with keys Major/Minor/Patch (all [int]) plus PreRelease # and Build (strings, possibly empty). Throws on invalid input. Pre-release @@ -190,8 +175,8 @@ function Compare-SemanticVersions { # # * CHANGE TYPE — the semantic intent of a release: 'breaking' / # 'non-breaking' / 'patch'. This is what the user thinks about; the change -# type for each released package is supplied in the `-Packages` argument -# to `release-packages.ps1` (e.g. `mypkg@breaking`, `mypkg@nonbreaking`). +# type for each released package is supplied by the release skill +# (e.g. `mypkg@breaking`, `mypkg@nonbreaking`). # Internally the same vocabulary is used for the `$changeType` enum (and # for `-ChangeType` parameters throughout the release tooling). # @@ -207,8 +192,7 @@ function Compare-SemanticVersions { # - For 0.0.x : every change -> 0.0.(x+1) (every change is breaking). # # DO NOT leak the internal `breaking|non-breaking|patch` enum directly into -# user-visible output without a translation step — use `Get-ChangeTypeLabel` -# in release-flow.ps1 to get a user-friendly noun phrase. +# user-visible output without translating `non-breaking` to `nonbreaking`. function Get-NextVersion { param( [string]$currentVersion, @@ -300,17 +284,412 @@ function Test-IsBreakingChange { # compare against) and ranks below every real change type. $script:ChangeTypeRank = @{ 'none' = 0; 'patch' = 1; 'non-breaking' = 2; 'breaking' = 3 } -# Returns whichever of two change types is the stronger (higher-ranked). Unknown -# or empty inputs are treated as 'none' (rank 0). Ties return $A. -function Get-StrongerChangeType { +# The Cargo compatibility line a concrete version belongs to: the leading +# non-zero component and everything above it, which is exactly the span a caret +# requirement admits. `1.4.2` and `1.9.0` share line `1`; `0.5.3` and `0.5.9` +# share line `0.5`; `0.0.3` is its own line. Two requirements pinned to +# different lines can never resolve to the same crate version, so a dependency +# whose line moved has a different type identity for every consumer that names +# its types. +function Get-CargoCompatibilityLine { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Version) + + $core = ($Version.Trim() -split '[-+]', 2)[0] + $parts = @($core -split '\.') + $numbers = New-Object 'System.Collections.Generic.List[int]' + foreach ($part in $parts) { + $value = 0 + if (-not [int]::TryParse($part.Trim(), [ref]$value)) { return $null } + $numbers.Add($value) | Out-Null + } + if ($numbers.Count -eq 0) { return $null } + + if ($numbers[0] -ne 0) { return $numbers[0].ToString() } + if ($numbers.Count -eq 1) { return '0' } + if ($numbers[1] -ne 0) { return "0.$($numbers[1])" } + if ($numbers.Count -eq 2) { return '0.0' } + return "0.0.$($numbers[2])" +} + +# The set of compatibility lines a Cargo requirement admits, or $null when the +# requirement is not understood well enough to answer. $null is a deliberate +# "unknown" that callers must fail closed on: guessing here would ship a +# dependency major bump as a compatible release. +# +# Comma-separated comparators are an AND, so they describe one line only when +# every comparator names it. A wildcard, an unbounded range, or a mix of lines +# is unknown. +function Get-CargoRequirementLines { + param([AllowNull()][AllowEmptyString()][string]$Requirement) + + if ([string]::IsNullOrWhiteSpace($Requirement)) { return $null } + + $lines = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($comparator in @($Requirement -split ',')) { + $text = $comparator.Trim() + if ([string]::IsNullOrWhiteSpace($text)) { continue } + + $match = [regex]::Match($text, '^(\^|~|=|>=|<=|>|<)?\s*(.+)$') + if (-not $match.Success) { return $null } + $operator = $match.Groups[1].Value + $version = $match.Groups[2].Value.Trim() + # `*`, `1.*` and friends span whatever the registry offers, and an + # inequality bound names a line it deliberately excludes. + if ($operator -in @('>=', '>', '<=', '<')) { return $null } + if ($version.Contains('*') -or $version.ToLowerInvariant().Contains('x')) { + return $null + } + + $line = Get-CargoCompatibilityLine -Version $version + if ($null -eq $line) { return $null } + [void]$lines.Add($line) + } + + if ($lines.Count -ne 1) { return $null } + return @($lines | Sort-Object) +} + +# Cargo's own spelling of a requirement: a bare version means caret. Comparing +# raw strings would report a change between a manifest's `3.0.2` and cargo +# metadata's `^3.0.2`, which are the same requirement. +function Get-NormalizedCargoRequirement { + param([AllowNull()][AllowEmptyString()][string]$Requirement) + + if ([string]::IsNullOrWhiteSpace($Requirement)) { return $null } + + # Idempotent over the ' || ' join below, so a requirement that has already + # been through Join-CargoRequirements normalizes to itself rather than to a + # second, differently spelled form. + $alternatives = @( + foreach ($alternative in ($Requirement -split '\|\|')) { + $terms = @( + foreach ($comparator in @($alternative -split ',')) { + $text = $comparator.Trim() + if ([string]::IsNullOrWhiteSpace($text)) { continue } + if ([regex]::IsMatch($text, '^[0-9]')) { + "^$($text -replace '\s+', '')" + } else { + $text -replace '\s+', '' + } + } + ) + if ($terms.Count -eq 0) { continue } + ($terms -join ', ') + } + ) + if ($alternatives.Count -eq 0) { return $null } + return ($alternatives -join ' || ') +} + +# Whether an external dependency requirement moved off the compatibility line it +# was released against. Anything the requirement grammar above cannot decide is +# breaking: an unreadable requirement change must not be able to ship a foreign +# type-identity change as a patch. +function Test-CargoRequirementBreaking { param( - [AllowNull()][AllowEmptyString()][string]$A, - [AllowNull()][AllowEmptyString()][string]$B + [AllowNull()][AllowEmptyString()][string]$BaselineRequirement, + [AllowNull()][AllowEmptyString()][string]$CurrentRequirement + ) + + $hasBaseline = -not [string]::IsNullOrWhiteSpace($BaselineRequirement) + $hasCurrent = -not [string]::IsNullOrWhiteSpace($CurrentRequirement) + # A newly declared dependency adds public surface; it cannot invalidate a + # type identity the released version never had. + if (-not $hasBaseline) { return $false } + # A dropped dependency removes whatever its types described. + if (-not $hasCurrent) { return $true } + if ($BaselineRequirement.Trim() -ceq $CurrentRequirement.Trim()) { return $false } + + $baselineLines = Get-CargoRequirementLines -Requirement $BaselineRequirement + $currentLines = Get-CargoRequirementLines -Requirement $CurrentRequirement + if ($null -eq $baselineLines -or $null -eq $currentLines) { return $true } + + foreach ($line in $baselineLines) { + if ($currentLines -notcontains $line) { return $true } + } + return $false +} + +# Splits manifest text into logical lines: a physical line, or the run of lines +# an unterminated inline table or array spans. Multi-line dependency entries are +# ordinary in this workspace (`syn = { workspace = true, features = [\n ... ] }`) +# and a per-physical-line reader would see only fragments of them. +function ConvertTo-CargoManifestLogicalLines { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$ManifestText) + + $logical = New-Object 'System.Collections.Generic.List[string]' + $buffer = $null + $depth = 0 + foreach ($rawLine in ($ManifestText -split "`r?`n")) { + $line = $rawLine + if ($null -eq $buffer -and $line.Trim() -match '^\[[^\[\]]*\]$|^\[\[[^\[\]]*\]\]$') { + $logical.Add($line.Trim()) | Out-Null + continue + } + + $inString = $false + $quote = '' + $escaped = $false + $cut = -1 + for ($i = 0; $i -lt $line.Length; $i++) { + $character = $line[$i] + if ($inString) { + if ($escaped) { $escaped = $false; continue } + if ($character -eq '\') { $escaped = $true; continue } + if ($character -eq $quote) { $inString = $false } + continue + } + switch -CaseSensitive ($character) { + '"' { $inString = $true; $quote = '"' } + "'" { $inString = $true; $quote = "'" } + '{' { $depth++ } + '[' { $depth++ } + '}' { if ($depth -gt 0) { $depth-- } } + ']' { if ($depth -gt 0) { $depth-- } } + '#' { $cut = $i } + } + if ($cut -ge 0) { break } + } + if ($cut -ge 0) { $line = $line.Substring(0, $cut) } + + if ($null -eq $buffer) { + $buffer = $line.Trim() + } else { + $buffer = "$buffer $($line.Trim())" + } + if ($depth -le 0) { + $depth = 0 + if (-not [string]::IsNullOrWhiteSpace($buffer)) { + $logical.Add($buffer.Trim()) | Out-Null + } + $buffer = $null + } + } + if (-not [string]::IsNullOrWhiteSpace($buffer)) { + $logical.Add($buffer.Trim()) | Out-Null + } + return $logical.ToArray() +} + +# Pulls the version requirement, workspace-inheritance flag and `package = "..."` +# rename out of one dependency value, in any of the forms Cargo accepts: +# `"1.2"`, `{ version = "1.2" }`, `{ workspace = true }`. +function ConvertFrom-CargoDependencyValue { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) + + $text = $Value.Trim() + $result = [pscustomobject]@{ + Requirement = $null + Inherited = $false + Package = $null + HasPath = $false + } + if ($text.StartsWith('"', [StringComparison]::Ordinal) -or + $text.StartsWith("'", [StringComparison]::Ordinal)) { + $result.Requirement = $text.Trim('"', "'") + return $result + } + + $version = [regex]::Match($text, '(? requirement record. +function Get-CargoManifestDependencies { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$ManifestText, + [ValidateSet('package', 'workspace')][string]$Section = 'package', + [hashtable]$WorkspaceRequirements + ) + + $sectionPattern = if ($Section -eq 'workspace') { + '^workspace\.dependencies(?:\.(?.+))?$' + } else { + '^(?:target\..+\.)?(?dependencies|build-dependencies)(?:\.(?.+))?$' + } + + $entries = [ordered]@{} + function Add-DependencyObservation { + param( + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $true)][string]$Kind, + [Parameter(Mandatory = $true)]$Parsed + ) + + if ($Parsed.HasPath -and [string]::IsNullOrWhiteSpace($Parsed.Requirement) -and + -not $Parsed.Inherited) { + return + } + $name = if (-not [string]::IsNullOrWhiteSpace($Parsed.Package)) { + $Parsed.Package + } else { + $Key + } + $normalized = $name.Replace('-', '_') + $requirement = $Parsed.Requirement + if ($Parsed.Inherited) { + $requirement = $null + if ($null -ne $WorkspaceRequirements -and + $WorkspaceRequirements.ContainsKey($normalized)) { + $requirement = $WorkspaceRequirements[$normalized] + } + } + if ([string]::IsNullOrWhiteSpace($requirement)) { return } + + if (-not $entries.Contains($normalized)) { + $entries[$normalized] = [pscustomobject]@{ + Name = $normalized + Requirements = New-Object 'System.Collections.Generic.List[string]' + Kinds = New-Object 'System.Collections.Generic.List[string]' + } + } + $record = $entries[$normalized] + if (-not $record.Requirements.Contains($requirement)) { + $record.Requirements.Add($requirement) | Out-Null + } + if (-not $record.Kinds.Contains($Kind)) { $record.Kinds.Add($Kind) | Out-Null } + } + + $currentSection = '' + $pendingEntry = $null + foreach ($line in ConvertTo-CargoManifestLogicalLines -ManifestText $ManifestText) { + $header = [regex]::Match($line, '^\[\s*([^\]]+?)\s*\]$') + if ($header.Success) { + if ($null -ne $pendingEntry) { + Add-DependencyObservation ` + -Key $pendingEntry.Key ` + -Kind $pendingEntry.Kind ` + -Parsed (ConvertFrom-CargoDependencyValue -Value "{ $($pendingEntry.Fields -join ', ') }") + $pendingEntry = $null + } + $currentSection = $header.Groups[1].Value.Trim() + $match = [regex]::Match($currentSection, $sectionPattern) + if ($match.Success -and $match.Groups['entry'].Success) { + # `[dependencies.syn]` collects its fields across the following + # lines, so the rename and the version are read together. + $pendingEntry = [pscustomobject]@{ + Key = $match.Groups['entry'].Value.Trim().Trim('"', "'") + Kind = if ($Section -eq 'workspace') { + 'normal' + } elseif ($match.Groups['kind'].Value -eq 'build-dependencies') { + 'build' + } else { + 'normal' + } + Fields = New-Object 'System.Collections.Generic.List[string]' + } + } + continue + } + + $assignment = [regex]::Match($line, '^([^=]+?)\s*=\s*(.+)$') + if (-not $assignment.Success) { continue } + $key = $assignment.Groups[1].Value.Trim().Trim('"', "'") + $value = $assignment.Groups[2].Value.Trim() + + if ($null -ne $pendingEntry) { + $pendingEntry.Fields.Add("$key = $value") | Out-Null + continue + } + + $match = [regex]::Match($currentSection, $sectionPattern) + if (-not $match.Success -or $match.Groups['entry'].Success) { continue } + $kind = if ($Section -eq 'workspace') { + 'normal' + } elseif ($match.Groups['kind'].Value -eq 'build-dependencies') { + 'build' + } else { + 'normal' + } + + $dotted = $key.IndexOf('.', [StringComparison]::Ordinal) + if ($dotted -gt 0) { + # `syn.workspace = true` and `syn.version = "1"` are the dotted-key + # spelling of the inline table. + $depName = $key.Substring(0, $dotted).Trim().Trim('"', "'") + $field = $key.Substring($dotted + 1).Trim() + $parsed = ConvertFrom-CargoDependencyValue -Value "{ $field = $value }" + Add-DependencyObservation -Key $depName -Kind $kind -Parsed $parsed + continue + } + + $parsed = ConvertFrom-CargoDependencyValue -Value $value + Add-DependencyObservation -Key $key -Kind $kind -Parsed $parsed + } + if ($null -ne $pendingEntry) { + Add-DependencyObservation ` + -Key $pendingEntry.Key ` + -Kind $pendingEntry.Kind ` + -Parsed (ConvertFrom-CargoDependencyValue -Value "{ $($pendingEntry.Fields -join ', ') }") + } + + $result = [ordered]@{} + foreach ($name in @($entries.Keys | Sort-Object { $_ } -CaseSensitive)) { + $record = $entries[$name] + $kinds = [string[]]@($record.Kinds.ToArray()) + [Array]::Sort($kinds, [StringComparer]::Ordinal) + $result[$name] = [pscustomobject]@{ + Name = $name + Requirement = Join-CargoRequirements -Requirements ([string[]]$record.Requirements.ToArray()) + Kinds = $kinds + } + } + return $result +} + +# Flattens the requirement records above into `name -> requirement string`, +# which is what [workspace.dependencies] inheritance needs. +function Get-CargoWorkspaceRequirements { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$ManifestText) + + $table = @{} + $entries = Get-CargoManifestDependencies ` + -ManifestText $ManifestText ` + -Section 'workspace' + foreach ($name in $entries.Keys) { + $requirement = $entries[$name].Requirement + if ([string]::IsNullOrWhiteSpace($requirement)) { continue } + $table[$name] = $requirement + } + return $table +} + +# One package may declare the same dependency more than once -- a normal and a +# build edge, or per-target variants. Joining the distinct requirements keeps a +# single deterministic entry per dependency; a package that really does declare +# two different requirements yields a string no requirement grammar accepts, +# which the breaking rule then treats as unknown and fails closed on. +function Join-CargoRequirements { + param([Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Requirements) + + $distinct = [string[]]@( + $Requirements | + ForEach-Object { Get-NormalizedCargoRequirement -Requirement $_ } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique ) - $ra = $script:ChangeTypeRank[$A]; if ($null -eq $ra) { $ra = 0 } - $rb = $script:ChangeTypeRank[$B]; if ($null -eq $rb) { $rb = 0 } - if ($rb -gt $ra) { return $B } - return $A + if ($distinct.Count -eq 0) { return $null } + [Array]::Sort($distinct, [StringComparer]::Ordinal) + return ($distinct -join ' || ') } # Reads the [package] table's `version = "..."` from a Cargo.toml on disk. @@ -467,7 +846,7 @@ function Get-PreviousVersionBumpCommit { $script:CachedWorkspaceMetadata = $null # Caches for git-derived data that is invariant for the entire script run. -# These are valid for the whole release-packages.ps1 invocation because: +# These are valid for the whole release-skill invocation because: # - $BaseRef is fixed by the caller for the entire run, and # - the script never makes git commits (HEAD does not move). # Therefore the per-package baseline commit, the per-package committed-changes @@ -499,19 +878,6 @@ function Get-WorkspaceMetadata { return $script:CachedWorkspaceMetadata } -# Invalidates the cached metadata. Call this after editing any Cargo.toml in the -# workspace so subsequent analyses see fresh deps/versions. -# -# Intentionally does NOT clear the git-derived caches -# (PackageLastReleaseBaselineCache, PackageCommittedChangesCache, -# PackageVersionAtRefCache) — those are keyed on git history, which the -# release script never mutates (no commits are made). Test isolation -# between scenarios should call Reset-ReleaseScriptCaches instead, which -# clears every cache including this one. -function Invalidate-WorkspaceMetadataCache { - $script:CachedWorkspaceMetadata = $null -} - # Clears every script-scoped cache used by the release tooling: workspace # metadata AND the git-derived per-package caches (baseline commit, committed # changes, version-at-BaseRef). Intended for test isolation between @@ -527,45 +893,6 @@ function Reset-ReleaseScriptCaches { $script:CrateSemverVerdictCache = $null } -# Memoised, mockable classifier: returns the minimum change type a crate's -# current working-tree public API requires versus its previous version-bump -# commit ('breaking' / 'non-breaking' / 'patch' / 'none' when there is no prior -# bump to compare against). Ordinary library crates are classified by running -# cargo-semver-checks once per crate. Proc-macro-only crates have no supported -# cargo-semver-checks API surface, so they return the explicit 'manual' result -# before the tool is invoked; the interactive planner owns that decision. -# Resolve-ReleaseSet is invoked many times during the interactive review loop, so -# results are cached per cargo name for the run. Test suites Mock this function to -# supply deterministic verdicts without invoking the real tool (see the scenario -# harness). -function Get-CrateRequiredChangeType { - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)][string]$Folder, - [Parameter(Mandatory = $true)][string]$CargoName, - [Parameter(Mandatory = $true)][string]$RepoRoot - ) - - if ($null -eq $script:CrateSemverVerdictCache) { $script:CrateSemverVerdictCache = @{} } - if ($script:CrateSemverVerdictCache.ContainsKey($CargoName)) { - return $script:CrateSemverVerdictCache[$CargoName] - } - - $workspacePackage = Get-WorkspacePackages -repoRoot $RepoRoot | - Where-Object { $_.Folder -eq $Folder -or $_.Name -eq $CargoName } | - Select-Object -First 1 - if ($null -ne $workspacePackage -and $workspacePackage.IsProcMacroOnly) { - Write-Host "cargo semver-checks: '$CargoName' is proc-macro-only; manual SemVer review is required." -ForegroundColor Yellow - $script:CrateSemverVerdictCache[$CargoName] = 'manual' - return 'manual' - } - - Write-Host "🔎 cargo semver-checks: analysing '$CargoName' against its previous version-bump commit..." -ForegroundColor Cyan - $result = Invoke-CrateSemverCheck -PackageName $CargoName -PackageFolder $Folder -RepoRoot $RepoRoot - $script:CrateSemverVerdictCache[$CargoName] = $result - return $result -} - # Returns information about all workspace packages as an array of objects with: # Name - cargo package name # Folder - folder name under crates/ (used as the script's PackageName argument) @@ -576,13 +903,21 @@ function Get-CrateRequiredChangeType { # alias, or the dependency's own `[lib] name`. An entry does not say # whether a separate unrenamed declaration also exists, so this is # not a complete or exclusive set of reachable roots. +# DepRoots - hashtable mapping a normalized dependency name to every +# crate root actually nameable through its declared edges. # CrateRoot - the package's own normalized crate root (its `[lib] name` when it # sets one, else its normalized package name), or $null when the # package has no library target at all. This is the name a crate's # types are written under by anything that does not rename it, so it # is the root an allowlist carries for a re-exported type. -# AllowedExternalTypes - array of strings from [package.metadata.cargo_check_external_types], -# or $null if the package does not declare them +# AllowedExternalTypes - array from [package.metadata.cargo_check_external_types] +# ExposureMetadataKnown - $true when allowed_external_types is explicitly present +# ExternalDeps - ordered map of normalized non-workspace dependency name -> +# @{ Requirement; Kinds }, with workspace inheritance already +# resolved by cargo. Dev dependencies are excluded: they never +# reach the published manifest. +# MacroRuntimePartners - normalized workspace package names from +# [package.metadata.oxidizer_release].macro_runtime # HasLibraryTarget - $true when cargo metadata reports a regular 'lib' target # IsProcMacroOnly - $true when the package has a 'proc-macro' target and no regular 'lib' target function Get-WorkspacePackages { @@ -605,7 +940,11 @@ function Get-WorkspacePackages { # cascade only ever asks about workspace packages, since a registry crate # is never a release target. $crateRootByPackage = @{} + $memberNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) foreach ($package in $metadata.packages) { + [void]$memberNames.Add($package.name.Replace('-', '_')) $libTarget = $package.targets | Where-Object { @($_.kind) -contains 'lib' -or @($_.kind) -contains 'proc-macro' } | Select-Object -First 1 @@ -621,6 +960,8 @@ function Get-WorkspacePackages { $deps = @() $depAliases = @{} + $depRoots = @{} + $externalDeps = [ordered]@{} foreach ($dep in $package.dependencies) { if ($dep.kind -eq 'dev') { continue @@ -629,6 +970,31 @@ function Get-WorkspacePackages { $depCargoName = $dep.name.Replace('-', '_') $deps += $depCargoName + # Cargo has already resolved [workspace.dependencies] inheritance + # into `req`, so this is the effective requirement the published + # manifest will carry -- the same value a consumer resolves against. + if (-not $memberNames.Contains($depCargoName)) { + if (-not $externalDeps.Contains($depCargoName)) { + $externalDeps[$depCargoName] = [pscustomobject]@{ + Name = $depCargoName + Requirements = New-Object 'System.Collections.Generic.List[string]' + Kinds = New-Object 'System.Collections.Generic.List[string]' + } + } + $externalRecord = $externalDeps[$depCargoName] + $requirement = ([string]$dep.req).Trim() + if ( + -not [string]::IsNullOrWhiteSpace($requirement) -and + -not $externalRecord.Requirements.Contains($requirement) + ) { + $externalRecord.Requirements.Add($requirement) | Out-Null + } + $externalKind = if ($dep.kind -eq 'build') { 'build' } else { 'normal' } + if (-not $externalRecord.Kinds.Contains($externalKind)) { + $externalRecord.Kinds.Add($externalKind) | Out-Null + } + } + # `rename` is the `package = "..."` form in Cargo.toml: the crate is # declared under one name but reachable in Rust source -- and hence # in an allowed_external_types entry -- only under the alias. Record @@ -640,6 +1006,8 @@ function Get-WorkspacePackages { # aliases (per-target or per-feature), so collect them all. $depAliases[$depCargoName] = @(@($depAliases[$depCargoName]) + $alias | Where-Object { $_ } | Sort-Object -Unique) + $depRoots[$depCargoName] = @(@($depRoots[$depCargoName]) + $alias | + Where-Object { $_ } | Sort-Object -Unique) } else { # No `package = "..."`, so the crate root is whatever the @@ -652,6 +1020,9 @@ function Get-WorkspacePackages { # `foo = { package = "bar" }` makes the crate nameable as `foo` # regardless of what bar calls its lib target. $crateRoot = $crateRootByPackage[$depCargoName] + $declaredRoot = if ($crateRoot) { $crateRoot } else { $depCargoName } + $depRoots[$depCargoName] = @(@($depRoots[$depCargoName]) + $declaredRoot | + Where-Object { $_ } | Sort-Object -Unique) if ($crateRoot -and $crateRoot -ne $depCargoName) { $depAliases[$depCargoName] = @(@($depAliases[$depCargoName]) + $crateRoot | Where-Object { $_ } | Sort-Object -Unique) @@ -659,14 +1030,30 @@ function Get-WorkspacePackages { } } - $allowedTypes = $null - $pkgMeta = $package.PSObject.Properties['metadata'] - if ($pkgMeta -and $null -ne $pkgMeta.Value) { - $externalTypes = $pkgMeta.Value.PSObject.Properties['cargo_check_external_types'] - if ($externalTypes -and $null -ne $externalTypes.Value) { - $allowed = $externalTypes.Value.PSObject.Properties['allowed_external_types'] - if ($allowed -and $null -ne $allowed.Value) { - $allowedTypes = @($allowed.Value) + $allowedExternalTypes = $null + $exposureMetadataKnown = $false + $macroRuntimePartners = @() + $packageMetadata = $package.PSObject.Properties['metadata'] + if ($packageMetadata -and $null -ne $packageMetadata.Value) { + $externalTypesMetadata = $packageMetadata.Value.PSObject.Properties['cargo_check_external_types'] + if ($externalTypesMetadata -and $null -ne $externalTypesMetadata.Value) { + $allowedTypes = $externalTypesMetadata.Value.PSObject.Properties['allowed_external_types'] + if ($allowedTypes -and $null -ne $allowedTypes.Value) { + $allowedExternalTypes = @($allowedTypes.Value) + $exposureMetadataKnown = $true + } + } + + $releaseMetadata = $packageMetadata.Value.PSObject.Properties['oxidizer_release'] + if ($releaseMetadata -and $null -ne $releaseMetadata.Value) { + $macroRuntime = $releaseMetadata.Value.PSObject.Properties['macro_runtime'] + if ($macroRuntime -and $null -ne $macroRuntime.Value) { + $macroRuntimePartners = @( + $macroRuntime.Value | + ForEach-Object { ([string]$_).Replace('-', '_') } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique + ) } } } @@ -674,17 +1061,33 @@ function Get-WorkspacePackages { $targetKinds = @($package.targets | ForEach-Object { @($_.kind) } | Sort-Object -Unique) $hasLibraryTarget = $targetKinds -contains 'lib' + $externalDepRecords = [ordered]@{} + foreach ($externalName in @($externalDeps.Keys | Sort-Object { $_ } -CaseSensitive)) { + $record = $externalDeps[$externalName] + $kinds = [string[]]@($record.Kinds.ToArray()) + [Array]::Sort($kinds, [StringComparer]::Ordinal) + $externalDepRecords[$externalName] = [pscustomobject]@{ + Name = $externalName + Requirement = Join-CargoRequirements -Requirements ([string[]]$record.Requirements.ToArray()) + Kinds = $kinds + } + } + $packages += [pscustomobject]@{ - Name = $package.name - Folder = Split-Path $manifestDir -Leaf - Version = $package.version - Published = -not ($null -ne $package.publish -and $package.publish.Count -eq 0) - Deps = $deps - DepAliases = $depAliases - CrateRoot = $crateRootByPackage[$package.name.Replace('-', '_')] - AllowedExternalTypes = $allowedTypes - HasLibraryTarget = $hasLibraryTarget - IsProcMacroOnly = (-not $hasLibraryTarget) -and ($targetKinds -contains 'proc-macro') + Name = $package.name + Folder = Split-Path $manifestDir -Leaf + Version = $package.version + Published = -not ($null -ne $package.publish -and $package.publish.Count -eq 0) + Deps = @($deps | Sort-Object -Unique) + DepAliases = $depAliases + DepRoots = $depRoots + CrateRoot = $crateRootByPackage[$package.name.Replace('-', '_')] + AllowedExternalTypes = $allowedExternalTypes + ExposureMetadataKnown = $exposureMetadataKnown + ExternalDeps = $externalDepRecords + MacroRuntimePartners = $macroRuntimePartners + HasLibraryTarget = $hasLibraryTarget + IsProcMacroOnly = (-not $hasLibraryTarget) -and ($targetKinds -contains 'proc-macro') } } @@ -815,6 +1218,64 @@ function Test-PackageExposesTarget { return $false } +# Returns $true only when a declared dependency's allowlist positively names +# the target. Unlike Test-PackageExposesTarget, absent or malformed metadata is +# not exposure evidence. This is used for proc-macro publication: depending on +# a macro does not make it part of a crate's public contract unless the crate +# explicitly re-exports it. +function Test-PackageAllowlistNamesDirectTarget { + param( + [Parameter(Mandatory = $true)][pscustomobject]$Dependent, + [Parameter(Mandatory = $true)][string]$TargetPackageName + ) + + if ($null -eq $Dependent.AllowedExternalTypes) { + return $false + } + + $normalizedTarget = $TargetPackageName.Replace('-', '_') + $acceptedRoots = @() + if ( + $null -ne $Dependent.PSObject.Properties['DepRoots'] -and + $null -ne $Dependent.DepRoots + ) { + $acceptedRoots = @( + $Dependent.DepRoots[$normalizedTarget] | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + } elseif ( + $null -ne $Dependent.PSObject.Properties['DepAliases'] -and + $null -ne $Dependent.DepAliases + ) { + $acceptedRoots = @( + $Dependent.DepAliases[$normalizedTarget] | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + } + if ($acceptedRoots.Count -eq 0) { + $acceptedRoots = @($normalizedTarget) + } + + foreach ($entry in $Dependent.AllowedExternalTypes) { + if ($entry -isnot [string] -or [string]::IsNullOrWhiteSpace($entry)) { + continue + } + + $root = ($entry -split '::', 2)[0] + if ([string]::IsNullOrWhiteSpace($root)) { + continue + } + if ($root.Contains('*') -or $root.Contains('?') -or $root.Contains('[')) { + continue + } + if ($acceptedRoots -contains $root) { + return $true + } + } + + return $false +} + # Returns $true when the dependent's allowlist is positive evidence that its # public API names types rooted at $TargetPackageName. # @@ -852,7 +1313,8 @@ function Test-PackageAllowlistNamesTarget { # Matters most on this path: a crate reached indirectly declares no edge # to the target, so it has no DepAliases entry for it. The target's own # crate root is the only place the diverted name can come from. - [string]$TargetCrateRoot + [string]$TargetCrateRoot, + [bool]$WildcardIsEvidence = $true ) if ($null -eq $Dependent.AllowedExternalTypes) { @@ -879,7 +1341,10 @@ function Test-PackageAllowlistNamesTarget { $root = ($entry -split '::', 2)[0] if ($root.Contains('*') -or $root.Contains('?') -or $root.Contains('[')) { - return $true + if ($WildcardIsEvidence) { + return $true + } + continue } if ($acceptedRoots -contains $root) { return $true @@ -889,68 +1354,6 @@ function Test-PackageAllowlistNamesTarget { return $false } -# Runs `cargo semver-checks` for a single crate against its previous version-bump -# commit in git history. The baseline commit is located with -# Get-PreviousVersionBumpCommit and passed to cargo-semver-checks as -# `--baseline-rev `, which rebuilds the baseline rustdoc from the crate's -# source at that commit — so the comparison source is what the repository last -# *declared*, with no registry access. This works identically in OSS and -# enterprise/offline environments and treats a declared-but-unpublished version as -# the baseline (unlike the former registry lookup). -# -# $BaseRef selects which bump counts as "previous"; the planner uses HEAD (the -# last committed version bump = the previous release). Returns the minimum change -# type the current working-tree API requires: 'breaking', 'non-breaking', 'patch', -# or 'none' when there is no prior version-bump commit to compare against (a -# brand-new crate). -# -# The current API is analysed from the working tree, not from HEAD, so a -# coordinated release's in-progress source edits are reflected rather than only -# what has been committed. -# -# That is necessary but NOT sufficient for exposed-dependency breaks, and this -# function must not be read as covering them. When a dependency's version bump -# is incompatible without its type *shapes* changing, this crate's rustdoc is -# identical on both sides of the comparison, so semver-checks correctly reports -# no required bump — yet releasing this crate compatibly is still wrong, because -# type identity in Rust is per-version: a consumer cannot hand a `dep 0.7` type -# to an API expecting `dep 0.8`. Nothing in a rustdoc diff can show that. -# -# Exposure is therefore decided separately, from the crate's declared -# allowed_external_types (Test-PackageExposesTarget), and propagated to a -# fixpoint by Resolve-ReleaseSet. The two are complementary: semver-checks -# supplies each crate's own floor, the exposure cascade supplies the floor its -# dependencies impose on it. -function Invoke-CrateSemverCheck { - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)][string]$PackageName, - [Parameter(Mandatory = $true)][string]$PackageFolder, - [Parameter(Mandatory = $true)][string]$RepoRoot, - [string]$BaseRef = 'HEAD' - ) - - # Locate the previous version-bump commit. No such commit => brand-new crate: - # nothing to compare against, so it imposes no change-type floor. - $bump = Get-PreviousVersionBumpCommit -RepoRoot $RepoRoot -BaseRef $BaseRef -PackageFolder $PackageFolder - if ($null -eq $bump) { - return 'none' - } - - Push-Location $RepoRoot - try { - # Manage the exit code manually; cargo-semver-checks exits non-zero when a - # bump is required, which is expected and not an error for our purposes. - $PSNativeCommandUseErrorActionPreference = $false - $output = & cargo semver-checks --package $PackageName --baseline-rev $bump.Sha --all-features --color never 2>&1 | Out-String - $exitCode = $LASTEXITCODE - } finally { - Pop-Location - } - - return ConvertFrom-SemverChecksOutput -Output $output -ExitCode $exitCode -PackageName $PackageName -} - # Parses `cargo semver-checks` combined output into a change type. Pure (no I/O) # so it can be unit-tested against captured tool output. With the git-history # baseline (`--baseline-rev`) cargo-semver-checks always builds the baseline from @@ -984,50 +1387,6 @@ function ConvertFrom-SemverChecksOutput { throw "cargo semver-checks did not produce a parseable result for '$PackageName' (exit $ExitCode). This usually means the tool is missing or the crate/baseline failed to build. Output:`n$Output" } -# BFS over the reverse dependency graph. Returns the folder names of all published -# workspace packages that depend on the given target (transitively) via [dependencies] -# or [build-dependencies]. The target itself is not included. -function Get-AllTransitiveDependents { - param( - [string]$packageName, - [string]$repoRoot - ) - - $packages = Get-WorkspacePackages -repoRoot $repoRoot - - $targetPackage = $packages | Where-Object { $_.Folder -eq $packageName -or $_.Name -eq $packageName } | Select-Object -First 1 - if ($null -eq $targetPackage) { - Write-Warning "Package '$packageName' not found in workspace metadata; cannot compute dependents." - return @() - } - $normalizedTarget = $targetPackage.Name.Replace('-', '_') - - $toVisit = [System.Collections.Generic.Queue[string]]::new() - $toVisit.Enqueue($normalizedTarget) - $visited = [System.Collections.Generic.HashSet[string]]::new() - [void]$visited.Add($normalizedTarget) - - $dependents = @() - while ($toVisit.Count -gt 0) { - $current = $toVisit.Dequeue() - foreach ($candidate in $packages) { - $candidateNorm = $candidate.Name.Replace('-', '_') - if ($visited.Contains($candidateNorm)) { - continue - } - if ($candidate.Deps -contains $current) { - [void]$visited.Add($candidateNorm) - $toVisit.Enqueue($candidateNorm) - if ($candidate.Published) { - $dependents += $candidate.Folder - } - } - } - } - - return $dependents -} - # Returns the published workspace packages that directly depend on a cargo # package in an already-captured metadata snapshot. This deliberately follows # exactly one dependency edge; callers can advance a review frontier only after @@ -1155,8 +1514,8 @@ function Get-PackageCommittedChanges { return $result } -# For each published workspace package, returns a hashtable folder -> ChangedFileCount -# where the count is the number of distinct repo-relative paths under crates// +# For each workspace package, returns a hashtable folder -> ChangedFiles where +# ChangedFiles is the sorted array of distinct repo-relative paths under crates// # that have changed since the package's last release baseline (see # Get-PackageLastReleaseBaseline). Considers: # @@ -1164,14 +1523,13 @@ function Get-PackageCommittedChanges { # - tracked working-tree edits (staged + unstaged) vs HEAD, # - untracked files (e.g. new source files added during a release run). # -# Packages with zero modifications are omitted from the result. -# # Working-tree edits and untracked files are queried once globally and bucketed # per package to avoid spawning O(packages) extra git processes. The per-package # committed diff is served from Get-PackageCommittedChanges' session cache. -function Get-PackagesWithUnreleasedChanges { +function Get-PackageUnreleasedChangeFiles { param( - [Parameter(Mandatory = $true)][string]$RepoRoot + [Parameter(Mandatory = $true)][string]$RepoRoot, + [switch]$IncludeUnpublished ) $result = @{} @@ -1192,7 +1550,7 @@ function Get-PackagesWithUnreleasedChanges { } foreach ($package in $packages) { - if (-not $package.Published) { continue } + if (-not $IncludeUnpublished -and -not $package.Published) { continue } $folder = $package.Folder $files = [System.Collections.Generic.HashSet[string]]::new() @@ -1206,13 +1564,32 @@ function Get-PackagesWithUnreleasedChanges { } if ($files.Count -gt 0) { - $result[$folder] = $files.Count + $sortedFiles = [string[]]@($files) + [Array]::Sort($sortedFiles, [StringComparer]::Ordinal) + $result[$folder] = $sortedFiles } } return $result } +# For each published workspace package, returns a hashtable folder -> ChangedFileCount. +function Get-PackagesWithUnreleasedChanges { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [switch]$IncludeUnpublished + ) + + $result = @{} + $filesByPackage = Get-PackageUnreleasedChangeFiles ` + -RepoRoot $RepoRoot ` + -IncludeUnpublished:$IncludeUnpublished + foreach ($folder in $filesByPackage.Keys) { + $result[$folder] = @($filesByPackage[$folder]).Count + } + return $result +} + # For every published workspace package, compares the on-disk current version with the # version at $BaseRef and returns the folders whose version differs. On-disk reads # avoid cache staleness when this is called between mid-run Cargo.toml edits. @@ -1250,571 +1627,3 @@ function Get-PackagesWithVersionChanges { # preserves it so callers' .Contains() calls still work. Write-Output -NoEnumerate $changed } - -# Returns a sorted array of pending-release records for every published workspace -# package whose on-disk Cargo.toml version differs from the version at $BaseRef. Each -# record exposes the data the announcement formatter and base-relative re-invocation -# logic need: -# -# [pscustomobject]@{ -# Folder = '' -# Name = '' -# BaseVersion = '' -# CurrentVersion = '' -# } -# -# New packages not present at $BaseRef are NOT included — they have no "base version" -# to compare against, and the rest of the script's flow treats them as fresh -# releases anyway (Invoke-PackageRelease writes the initial Cargo.toml + changelog -# entry). Only packages that genuinely have a prior committed version with a -# different on-disk version qualify as "pending" in the cross-invocation sense. -# -# Sorted ascending by Folder for deterministic output (the announcement order -# must be stable across runs / hosts / etc.). -function Get-PendingReleases { - param( - [Parameter(Mandatory = $true)][string]$RepoRoot, - [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$BaseRef - ) - - $packages = Get-WorkspacePackages -repoRoot $RepoRoot - $pending = New-Object System.Collections.Generic.List[object] - - foreach ($package in $packages) { - if (-not $package.Published) { continue } - - $cargoToml = Join-Path $RepoRoot "crates/$($package.Folder)/Cargo.toml" - if (-not (Test-Path $cargoToml)) { continue } - - $currentVersion = Get-CurrentVersion -cargoTomlPath $cargoToml - $baseVersion = Get-PackageVersionFromRef -RepoRoot $RepoRoot -BaseRef $BaseRef -PackageFolder $package.Folder - - # New package at base: skip (no base version to be pending against). - if ($null -eq $baseVersion) { continue } - if ($currentVersion -eq $baseVersion) { continue } - - $pending.Add([pscustomobject]@{ - Folder = $package.Folder - Name = $package.Name - BaseVersion = $baseVersion - CurrentVersion = $currentVersion - }) | Out-Null - } - - return @($pending | Sort-Object -Property Folder) -} - -# Builds a ResolvedReleaseSet (folder -> resolved entry) from base-ref vs disk -# version diffing. Used as a test utility to synthesise a release set from a -# synthetic-workspace git diff without having to construct one entry-by-entry; -# production code uses Resolve-ReleaseSet in release-flow.ps1 (driven by -# explicit user input). -# -# Every member is marked Source='cascade' so the elevation-surface predicate -# in Get-UnreleasedModifiedDependencies treats every release-set member as -# potentially-elevatable. This matches the bundled-input semantics: in -# the absence of explicit user intent, every below-breaking release-set -# member is surfaced for review. -# -# New packages (no version at $BaseRef) are tagged 'breaking' so the -# elevation predicate skips them — they have no prior version transition to -# elevate. This matches the pre-refactor null-base-version guard behavior. -function New-ResolvedReleaseSetFromBaseRef { - param( - [Parameter(Mandatory = $true)][string]$RepoRoot, - [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$BaseRef - ) - - $resolved = @{} - $folders = Get-PackagesWithVersionChanges -RepoRoot $RepoRoot -BaseRef $BaseRef - if ($null -eq $folders -or $folders.Count -eq 0) { return $resolved } - - $packages = Get-WorkspacePackages -repoRoot $RepoRoot - $pkgByFolder = @{} - foreach ($p in $packages) { $pkgByFolder[$p.Folder] = $p } - - foreach ($folder in $folders) { - if (-not $pkgByFolder.ContainsKey($folder)) { continue } - $pkg = $pkgByFolder[$folder] - $baseVersion = Get-PackageVersionFromRef -RepoRoot $RepoRoot -BaseRef $BaseRef -PackageFolder $folder - $changeType = if ($null -eq $baseVersion) { - # New package: no semantically-meaningful prior version to elevate from. - 'breaking' - } else { - Get-ChangeTypeFromVersions -oldVersion $baseVersion -newVersion $pkg.Version - } - $resolved[$folder] = [pscustomobject]@{ - Folder = $folder - Name = $pkg.Name - CurrentVersion = $baseVersion - EffectiveChangeType = $changeType - EffectiveTargetVersion = $pkg.Version - Source = 'cascade' - AutoUpgraded = $false - CascadeReasons = New-Object 'System.Collections.Generic.List[object]' - } - } - - return $resolved -} - -# --- CORE ANALYSIS --- -# -# Upholds the CASCADE-ORGANIZATION INVARIANTS documented in docs/releasing.md -# under "Cascade Organisation Invariants": -# (A) A cascade toward dependents never introduces items to the user-review -# queue. Honored via the optional -ModifiedSnapshot parameter: when -# callers capture the modifications set BEFORE the primary release -# runs and pass it in, cascade-only targets (those whose only -# modification is the cascade-written Cargo.toml / CHANGELOG.md) never -# enter the snapshot and so cannot surface as findings on later -# iterations. -# (B) A release-set member whose cascade-applied change type is below the -# semantic maximum (breaking) and which has pre-existing modifications -# is reported so the user can still elevate the change type after -# reviewing the changes. User-source members (Source='user' in the -# resolved set) carry an explicit decision and are NOT re-prompted — -# elevation review applies only to cascade-source members. -# -# For each package in the "resolved release set" (passed in by the caller as a -# folder -> resolved-entry hashtable produced by Resolve-ReleaseSet, or by -# tests via the New-ResolvedReleaseSetFromBaseRef helper), walk its transitive -# normal/build workspace dependencies. Report any workspace dependency that -# -# 1. has source modifications since its own last release baseline (i.e. since the -# most recent commit that touched its `version =` or `publish =` line — see -# Get-PackageLastReleaseBaseline), and -# 2. is either (a) NOT itself in the release set, OR (b) IS in the release set -# as a cascade-source member whose EffectiveChangeType is below "breaking" -# (so the user might still want to elevate it after reviewing the changes), and -# 3. is published (publish != false), -# -# along with the shortest dependency chain that reaches it from a released package. -# -# A BFS root only counts as "released" for this analysis when the release-set -# member itself has source modifications past its release baseline (i.e. is -# in the modifications map). A pure-cascade member (version bump only, no -# source changes of its own) cannot have started consuming unreleased -# features in its dependencies because nothing in its source changed — BFS -# from such a member would only produce false positives, so it is skipped. -# -# Per-package baselines (rather than a global PR-vs-base-ref diff) are required to -# detect transitive dependency changes that were merged to main in earlier PRs without -# a version change and are now being depended on by a release-set package in this PR. -# Comparing the working tree only against the PR base ref would miss those. -# -# Returns @() when there are no findings, otherwise an array of objects: -# Folder - package folder under crates/ -# PackageName - cargo package name -# CurrentVersion - package's current version (Cargo.toml [package].version) -# InReleaseSet - $true when the finding is also a release-set member -# surfaced for cascade elevation review (Source='cascade' -# with below-breaking change type); $false otherwise. -# The caller uses this to distinguish "needs review for -# elevation" from "needs review for primary release". -# PlannedCurrentVersion - release plan's starting version, or $null when -# the package is not yet in the release set -# EffectiveChangeType - release level already in the plan, or $null -# EffectiveTargetVersion - target version already in the plan, or $null -# ChangedFileCount - number of files changed under crates// since baseline -# DependencyChains - @( @('released_package', 'mid_package', 'this_dep'), ... ) -# - chains rooted in release-set members (or, in -# -IncludeAllModifiedAsRoots mode, also in other -# modified-published packages) that transitively reach -# `this_dep`. Used by the interactive review prompt to -# highlight what is at risk in the current release plan -# specifically. -# WorkspaceDependencyChains - @( @('top_dependent', ..., 'this_dep'), ... ) -# - every path in the workspace dep graph ending at -# `this_dep`, irrespective of release-set membership. -# Used by the interactive per-package menu to give the -# reviewer a release-set-independent "big picture" view -# of what could be affected by releasing this package. -# -# The BFS traverses past every node (including release-set members) so a chain -# like 'foo -> bar -> baz' is recorded even when 'bar' is itself being -# released. Chains are then reduced (deduped + suffix-subsumed) so a shorter -# chain that is a strict suffix of a longer one (e.g. 'bar -> baz' vs -# 'foo -> bar -> baz') is dropped to keep the prompt focused on the longest -# path from each release-set entry point. -function Get-UnreleasedModifiedDependencies { - param( - [Parameter(Mandatory = $true)][string]$RepoRoot, - [Parameter(Mandatory = $true)][hashtable]$ResolvedReleaseSet, - [Parameter(Mandatory = $false)][hashtable]$ModifiedSnapshot, - # When set, treats every modified-published package as an additional BFS - # root (in addition to ResolvedReleaseSet members) so chains BETWEEN - # changed packages surface naturally, AND sweeps any modified-published - # package the surfacing predicate accepts but no BFS run reached as a - # dep, adding it as a "stub" finding (DependencyChains = @()). Used by - # the guided changed-packages workflow (release-packages.ps1 -Changed / -All). - [switch]$IncludeAllModifiedAsRoots - ) - - $packages = Get-WorkspacePackages -repoRoot $RepoRoot - # Use the caller-provided snapshot when present so Invariant A holds across - # cascade writes (which would otherwise pollute Get-PackagesWithUnreleasedChanges's - # working-tree query and surface cascade-only targets as findings). - $modifiedMap = if ($PSBoundParameters.ContainsKey('ModifiedSnapshot') -and $null -ne $ModifiedSnapshot) { - $ModifiedSnapshot - } else { - Get-PackagesWithUnreleasedChanges -RepoRoot $RepoRoot - } - - if ($IncludeAllModifiedAsRoots) { - if ($ResolvedReleaseSet.Count -eq 0 -and $modifiedMap.Count -eq 0) { return @() } - } else { - if ($ResolvedReleaseSet.Count -eq 0) { return @() } - } - - # Build folder -> package lookup and normalized-name -> folder lookup. - $byFolder = @{} - $folderByNormName = @{} - foreach ($c in $packages) { - $byFolder[$c.Folder] = $c - $folderByNormName[$c.Name.Replace('-', '_')] = $c.Folder - } - - # Local closure: decide whether a modified-published package should surface - # as a finding given its release-set membership. Centralised so the BFS - # body (which checks a *visited dep*) and the Phase B sweep (which checks - # a *root*) share the same predicate. Surface when (modified + published) - # AND either: - # - not a release-set member (classic case), OR - # - a release-set member with Source='cascade' whose EffectiveChangeType - # is below "breaking" (Invariant B — elevation review). Source='user' - # members carry an explicit decision from the CLI input and are NOT - # re-prompted. - $shouldSurface = { - param([string]$folder) - $pkg = $byFolder[$folder] - if ($null -eq $pkg) { return $false } - if (-not ($modifiedMap.ContainsKey($folder) -and $pkg.Published)) { return $false } - $entry = $ResolvedReleaseSet[$folder] - if ($null -eq $entry) { return $true } - return ($entry.Source -eq 'cascade' -and $entry.EffectiveChangeType -ne 'breaking') - }.GetNewClosure() - - # Aggregate findings: folder -> { Folder; PackageName; ChangedFileCount; DependencyChains }. - # Ordered so the BFS insertion order is preserved when iterating .Values; matters because - # the post-release scan prompts the user in this order and a non-deterministic order - # makes the UX flaky and tests unreliable. - $findings = [ordered]@{} - - # Compute BFS roots. In the default (targeted) mode they're the - # release-set members WHOSE SOURCE/FILES HAVE BEEN MODIFIED past their - # per-package release baseline. Pure-cascade members (no source changes - # of their own, version bump only) cannot have started consuming - # unreleased features in their dependencies, so BFS from them is - # categorically incapable of producing a real finding — only false - # positives — and is skipped. The same modified-precondition applies in - # -IncludeAllModifiedAsRoots mode, where it's redundant with the - # modifiedMap union below (release-set membership adds nothing once - # modified-published already covers it) but kept for symmetry / clarity. - # When -IncludeAllModifiedAsRoots is set we also add every - # modified-published package so chains between changed packages can be - # recorded (e.g. 'bytesbuf_io -> bytesbuf' when both are changed and - # bytesbuf_io depends on bytesbuf). Sorted for deterministic prompt order. - $rootFolders = if ($IncludeAllModifiedAsRoots) { - $set = [System.Collections.Generic.HashSet[string]]::new() - foreach ($k in $ResolvedReleaseSet.Keys) { - if ($modifiedMap.ContainsKey($k)) { [void]$set.Add($k) } - } - foreach ($k in $modifiedMap.Keys) { - $pkg = $byFolder[$k] - if ($null -ne $pkg -and $pkg.Published) { [void]$set.Add($k) } - } - @($set | Sort-Object) - } else { - @($ResolvedReleaseSet.Keys | Where-Object { $modifiedMap.ContainsKey($_) } | Sort-Object) - } - - foreach ($releasedFolder in $rootFolders) { - if (-not $byFolder.ContainsKey($releasedFolder)) { continue } - - # BFS forward over normal+build deps. Track shortest path to each visited - # node within this start-package's traversal (avoids cycles and keeps the - # recorded chain to the SHORTEST path from this entry point). - $visited = [System.Collections.Generic.HashSet[string]]::new() - [void]$visited.Add($releasedFolder) - $queue = [System.Collections.Generic.Queue[object]]::new() - $queue.Enqueue([pscustomobject]@{ Folder = $releasedFolder; Chain = @($releasedFolder) }) - - while ($queue.Count -gt 0) { - $node = $queue.Dequeue() - $package = $byFolder[$node.Folder] - if ($null -eq $package) { continue } - - foreach ($depNorm in $package.Deps) { - if (-not $folderByNormName.ContainsKey($depNorm)) { continue } # external package - $depFolder = $folderByNormName[$depNorm] - if ($visited.Contains($depFolder)) { continue } - [void]$visited.Add($depFolder) - - $depPackage = $byFolder[$depFolder] - $depChain = $node.Chain + $depFolder - - if (& $shouldSurface $depFolder) { - $depEntry = $ResolvedReleaseSet[$depFolder] - $isInReleaseSet = $null -ne $depEntry - if (-not $findings.Contains($depFolder)) { - $findings[$depFolder] = [pscustomobject]@{ - Folder = $depFolder - PackageName = $depPackage.Name - CurrentVersion = $depPackage.Version - InReleaseSet = $isInReleaseSet - PlannedCurrentVersion = if ($isInReleaseSet) { $depEntry.CurrentVersion } else { $null } - EffectiveChangeType = if ($isInReleaseSet) { $depEntry.EffectiveChangeType } else { $null } - EffectiveTargetVersion = if ($isInReleaseSet) { $depEntry.EffectiveTargetVersion } else { $null } - ChangedFileCount = $modifiedMap[$depFolder] - DependencyChains = @(, $depChain) - RequiresManualSemverReview = [bool]$depPackage.IsProcMacroOnly - } - } - else { - $existing = $findings[$depFolder] - $existing.DependencyChains = @($existing.DependencyChains) + @(, $depChain) - } - } - - # Traverse past every node — release-set members, unchanged - # intermediates, and recorded findings alike. This lets us - # surface chains that thread through release-set members to a - # deeper modified-and-unreleased target (e.g. 'foo -> bar -> baz' - # where 'bar' is being released and 'baz' is not). - $queue.Enqueue([pscustomobject]@{ Folder = $depFolder; Chain = $depChain }) - } - } - } - - # Phase B sweep: every BFS root the surfacing predicate accepts but no - # BFS run reached as a dep gets added as a stub finding (empty chains). - # Two reasons this matters: - # - # 1. -IncludeAllModifiedAsRoots mode: every modified-published package - # that isn't BFS-reachable from another root surfaces as a stub. - # Renders as "No dependents in release set" in the menu — the - # "imaginary `*` package depends on every changed package" UX - # without introducing a sentinel. - # - # 2. Targeted mode (Invariant B — release-set elevation review): - # release-set members that are themselves modified BUT whose - # cascade-applied change type is below "breaking" need to surface - # for elevation review. With the LIVE filter applied to BFS root - # selection, only release-set members IN modifiedMap are roots, - # so this sweep over rootFolders is exactly the set of candidates - # that qualify for Invariant B. The shouldSurface predicate - # filters out user-source members and breaking-cascade members, - # leaving only cascade-source below-breaking entries — i.e. - # release-set members the user may want to elevate after diff - # review. - foreach ($folder in $rootFolders) { - if ($findings.Contains($folder)) { continue } - if (-not (& $shouldSurface $folder)) { continue } - $pkg = $byFolder[$folder] - $entry = $ResolvedReleaseSet[$folder] - $findings[$folder] = [pscustomobject]@{ - Folder = $folder - PackageName = $pkg.Name - CurrentVersion = $pkg.Version - InReleaseSet = $null -ne $entry - PlannedCurrentVersion = if ($null -ne $entry) { $entry.CurrentVersion } else { $null } - EffectiveChangeType = if ($null -ne $entry) { $entry.EffectiveChangeType } else { $null } - EffectiveTargetVersion = if ($null -ne $entry) { $entry.EffectiveTargetVersion } else { $null } - ChangedFileCount = $modifiedMap[$folder] - DependencyChains = @() - RequiresManualSemverReview = [bool]$pkg.IsProcMacroOnly - } - } - - if ($findings.Count -eq 0) { return @() } - - # Reduce each finding's chains: drop duplicates and shorter chains that are - # strict suffixes of a longer chain, so the user sees only the longest - # caller-rooted path through each branch. - foreach ($f in $findings.Values) { - if ($null -ne $f.DependencyChains -and @($f.DependencyChains).Count -gt 0) { - $f.DependencyChains = Reduce-DependencyChains -Chains $f.DependencyChains - } - } - - # Populate WorkspaceDependencyChains: every path in the workspace dep graph - # of the form `[root, ..., target]` ending at this finding's folder. Used - # by the interactive menu to give the user a release-set-independent - # picture of what could be affected by releasing the package under review - # (cascading can pull more dependents into the release set after the - # review prompt, so the release-set-rooted DependencyChains list would - # otherwise be misleadingly narrow). Computed here (not at menu render - # time) so $packages is reused and no extra cargo metadata invocations - # happen per prompt. - foreach ($f in $findings.Values) { - $f | Add-Member -NotePropertyName WorkspaceDependencyChains -NotePropertyValue ( - Get-InWorkspaceDependencyChains -Packages $packages -TargetFolder $f.Folder - ) -Force - } - - return @($findings.Values) -} - -# Deduplicates dependency chains and drops chains that are strict suffixes of -# any other kept chain. Returns a stable-sorted array (alphabetical by joined -# chain text) so the UX prompt and the PR comment render deterministically. -# -# A chain X is "subsumed by" chain Y when Y is strictly longer than X and X -# equals the tail of Y element-for-element. Subsumption is one-directional — -# we keep the LONGER chain because it carries strictly more context for the -# reviewer (the same suffix plus its caller ancestry). -function Reduce-DependencyChains { - param( - [Parameter(Mandatory = $true)] - [AllowEmptyCollection()] - [object[]]$Chains - ) - - if ($null -eq $Chains -or $Chains.Count -eq 0) { return @() } - - # Step 1: dedupe by canonical string key (preserves the first occurrence). - $seen = [ordered]@{} - foreach ($c in $Chains) { - $arr = @($c) - $key = $arr -join "`u{2192}" # rightwards arrow as a separator unlikely to collide - if (-not $seen.Contains($key)) { $seen[$key] = $arr } - } - $unique = @($seen.Values) - - # Step 2: sort by length descending and keep each chain only when no - # already-kept (longer) chain has it as a strict suffix. - $sortedByLengthDesc = @($unique | Sort-Object @{ Expression = { $_.Length }; Descending = $true }) - $kept = New-Object System.Collections.Generic.List[object] - foreach ($c in $sortedByLengthDesc) { - $isSuffix = $false - foreach ($k in $kept) { - if ($c.Length -ge $k.Length) { continue } # strict suffix requires shorter length - $offset = $k.Length - $c.Length - $match = $true - for ($i = 0; $i -lt $c.Length; $i++) { - if ($c[$i] -ne $k[$offset + $i]) { $match = $false; break } - } - if ($match) { $isSuffix = $true; break } - } - if (-not $isSuffix) { [void]$kept.Add($c) } - } - - # Step 3: stable alphabetical sort by joined chain text so output order - # is deterministic across runs and across release-set iteration order. - $finalSorted = @($kept | Sort-Object { ($_ -join ' -> ') }) - # IMPORTANT: prefix the return with `,` to prevent PowerShell from - # unwrapping a single-element array-of-arrays into its inner array, - # which would silently corrupt $finding.DependencyChains[0] when only - # one chain survives reduction (caller would see a flat string array - # instead of an array containing one chain). - return ,$finalSorted -} - -# Computes the set of in-workspace dependency chains that end at $TargetFolder -# - i.e. every path through the workspace package dep graph of the form -# `[root, ..., target]` where `root` is some workspace package that -# transitively depends on `target` and `root` itself has no in-workspace -# dependent (the chain reaches as far up the dependency tree as possible). -# Used by `Format-PackageMenu` to give the user a "big picture" view of what -# could be affected by releasing the package under review - independent of -# which packages are in the current release set, since cascading can bring -# in more dependents after the review prompt is shown. -# -# `$Packages` is the already-loaded workspace package list (output of -# `Get-WorkspacePackages`); pass it in to avoid re-running `cargo metadata` -# when the caller already has it. -# -# Returns @() when $TargetFolder is unknown, or when no other workspace -# package transitively depends on it. Otherwise returns chains reduced via -# `Reduce-DependencyChains` (suffix-subsumed shorter chains dropped). Dev -# dependencies and non-`crates/` workspace members are NOT included, since -# `Get-WorkspacePackages` already filters them out - this matches the -# release-impact semantics we care about (dev-dep changes don't affect a -# package's published-API consumers). -function Get-InWorkspaceDependencyChains { - param( - [Parameter(Mandatory = $true)] - [AllowEmptyCollection()] - [object[]]$Packages, - [Parameter(Mandatory = $true)][string]$TargetFolder - ) - - # PowerShell unwraps a bare `return @()` to $null at the function - # boundary (the empty array contributes 0 items to the output stream). - # Prefix returns with `,` to force an array-preserving single-item - # output - the receiver sees the array (possibly empty), not $null. - if ($null -eq $Packages -or $Packages.Count -eq 0) { return ,@() } - - # Build folder -> package and normalized-name -> folder lookups (same shape - # the BFS in Get-UnreleasedModifiedDependencies builds for forward edges). - $byFolder = @{} - $folderByNormName = @{} - foreach ($p in $Packages) { - $byFolder[$p.Folder] = $p - $folderByNormName[$p.Name.Replace('-', '_')] = $p.Folder - } - if (-not $byFolder.ContainsKey($TargetFolder)) { return ,@() } - - # Reverse adjacency: depFolder -> list of folders that depend on depFolder. - $reverse = @{} - foreach ($p in $Packages) { - foreach ($depNorm in $p.Deps) { - if (-not $folderByNormName.ContainsKey($depNorm)) { continue } # external - $depFolder = $folderByNormName[$depNorm] - if (-not $reverse.ContainsKey($depFolder)) { - $reverse[$depFolder] = New-Object 'System.Collections.Generic.List[string]' - } - [void]$reverse[$depFolder].Add($p.Folder) - } - } - - # Iterative DFS over reverse edges starting at $TargetFolder. Each stack - # entry carries the path-so-far in REVERSE order (target first, current - # frontier last) so cycle detection is a quick membership check. When a - # frontier has no further dependents (workspace root reached), we emit the - # reversed path as a chain `[root, ..., target]`. Cycles can't exist in a - # valid Cargo workspace, but defensive `notcontains` keeps the loop safe - # if metadata ever yields one. - $chains = New-Object 'System.Collections.Generic.List[object]' - $stack = [System.Collections.Generic.Stack[object]]::new() - $stack.Push([pscustomobject]@{ - Folder = $TargetFolder - ReversedPath = @($TargetFolder) - }) - - while ($stack.Count -gt 0) { - $node = $stack.Pop() - $candidates = @() - if ($reverse.ContainsKey($node.Folder)) { - foreach ($d in $reverse[$node.Folder]) { - if ($node.ReversedPath -notcontains $d) { $candidates += $d } - } - } - - if ($candidates.Count -eq 0) { - # Reached a top-level dependent (or all further dependents would - # cycle). Skip the trivial single-element [target] "chain" - there - # is nothing to display when target has no in-workspace dependents. - if ($node.ReversedPath.Length -gt 1) { - $chain = New-Object 'System.Collections.Generic.List[string]' - for ($i = $node.ReversedPath.Length - 1; $i -ge 0; $i--) { - [void]$chain.Add($node.ReversedPath[$i]) - } - [void]$chains.Add(@($chain)) - } - } else { - foreach ($d in $candidates) { - $stack.Push([pscustomobject]@{ - Folder = $d - ReversedPath = $node.ReversedPath + $d - }) - } - } - } - - if ($chains.Count -eq 0) { return ,@() } - # Reduce-DependencyChains already returns ,$finalSorted, so its non-empty - # array structure survives this forward. - return Reduce-DependencyChains -Chains $chains -} diff --git a/scripts/release-packages.ps1 b/scripts/release-packages.ps1 deleted file mode 100644 index b637f5aff..000000000 --- a/scripts/release-packages.ps1 +++ /dev/null @@ -1,241 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -#Requires -Version 7.0 - -<# -.SYNOPSIS - Releases one or more workspace packages from a single bundled plan. - -.DESCRIPTION - The driver supports three mutually-exclusive modes for selecting which - workspace packages to release. In every mode the same pipeline - runs: plan resolution + cascade toward dependents, an elevation review for - any modified-but-unreleased dependencies, a final plan display, and atomic - execution of all Cargo.toml / CHANGELOG.md / README.md / workspace - Cargo.toml writes, followed by a workspace `cargo check` and a summary. - - Every mode is interactive — even the targeted mode prompts for elevation - review when modified-but-unreleased dependencies of the requested packages - are detected. The script must be run from an interactive terminal. - - Modes: - - 1. Targeted (-Packages, default). - The caller provides the entire release plan up front as a list of - `@` tokens. The planner cascades toward dependents - and surfaces any modified-and-unreleased dependencies for review. - - 2. Changed (-Changed). - Guided walk: the planner scans the workspace for every package with - unreleased modifications (changes newer than its last `version =` / - `publish =` commit) and walks the user through them one prompt at a - time. For each surfaced package the user can view the diff, skip the - package, or release it as breaking / non-breaking / patch. Each - acceptance is fed back to the planner, which re-resolves the release - set and cascade so the next iteration surfaces only newly-relevant - elevation candidates. - - The change scan only sees files under `crates//`. Modifications - to anything outside a package directory — for example the workspace-level - `Cargo.toml`, `.cargo/`, `deny.toml`, or shared CI configuration — do - NOT surface a package as "modified" even if they affect how the package - builds or behaves. If you suspect such a cross-cutting change matters, - use `-All` (which walks every publishable package regardless of detected - changes) or list the affected packages explicitly via `-Packages`. - - 3. All (-All). - Same guided walk as -Changed, but the change-detection scan is - skipped: every publishable workspace package is surfaced for review, - even ones with no on-disk modifications. Use this when you want to - force-walk the entire workspace (e.g. preparing a coordinated multi- - package release after a refactor that may have touched everything). - Surfaced packages with no detected changes still expose the View-diff - menu option (relabelled to make the empty state obvious). - - Cargo's 0.x.y SemVer rules are honored throughout: for `0.x.y` packages a - Breaking change becomes `0.(x+1).0`, NonBreaking and Patch both map to - incrementing `y`. Every ordinary library release in the plan is classified - by running `cargo semver-checks` against the crate's previous version-bump - commit in git history (the most recent commit that changed the crate's - `[package] version`, with the baseline rustdoc rebuilt from source via - `--baseline-rev`, so no registry access is required and it works in OSS and - enterprise/offline environments alike). Because rustdoc comparison cannot - identify an incompatible version change in an otherwise-unchanged exposed - dependency type, the planner also consults each dependent's - `[package.metadata.cargo_check_external_types].allowed_external_types`. - Exposing a dependency whose planned version transition is breaking floors - the dependent at `breaking`, recursively. This applies to direct dependency - edges, and also to a transitive dependency whose types the dependent - explicitly allowlists — cargo-check-external-types attributes a re-exported - type to the crate that defines it, so `fetch_azure` allowlists - `typespec_client_core` while depending on `azure_core`. - Other unaffected dependents cascade as `patch` so they still pick up the new - dependency version. Dev-only dependents are skipped — they automatically - pick up the new workspace version. - - Proc-macro-only packages are detected from `cargo metadata` before - cargo-semver-checks runs. The tool cannot inspect procedural macro names, - accepted inputs, diagnostics, or generated output, so every proc-macro-only - package in the release set is shown in the standard diff + release-decision - dialog for explicit manual classification. This includes targeted packages - and unchanged proc-macro dependents added by cascade. Build/test success is - separate validation and does not prove procedural macro API compatibility. - A breaking manual result triggers the same mandatory review for direct - published consumers. Their provisional level remains the stronger of the - patch cascade floor and their own cargo-semver-checks result; the proc-macro - severity is not copied. Review advances another edge only when that - consumer's final result is breaking, and stops on any weaker result. - - cargo-semver-checks remains a hard dependency for ordinary library packages - (install the version pinned in constants.env). Missing external-type metadata - is treated conservatively as possible exposure. - - User-provided change types may be automatically upgraded by this analysis - if the crate's real API diff requires a stronger change type (e.g. a - dependent that re-exports a breaking change is upgraded from your requested - `patch` to `breaking`). If an explicit version number is specified for a - package and the analysis requires a higher version number than the pin - allows, the release plan is rejected (or, with -Force, the pin is honored - verbatim and a warning is printed flagging that consumers may break). - -.PARAMETER Packages - The list of workspace packages to release, in the form - `@`. Names match the folder name under `crates/` (or - the Cargo package name if it differs by `_`/`-`). Accepted change specs: - - - `breaking` : SemVer-incompatible change. 1.2.3 -> 2.0.0; - 0.4.1 -> 0.5.0; 0.0.5 -> 0.0.6. - - `nonbreaking` : SemVer-compatible feature/addition. - 1.2.3 -> 1.3.0; 0.4.1 -> 0.4.2; 0.0.5 -> 0.0.6. - - `patch` : SemVer-compatible internal change. 1.2.3 -> 1.2.4; - 0.4.1 -> 0.4.2 (numerically equal to nonbreaking - on 0.x.y packages). - - `..[-][+]` : explicit SemVer 2.0 - version pin. Must have exactly three numeric - components — 1- or 2-component forms like `1` or - `1.2` are rejected. Examples: `1.0.0`, `2.5.0`, - `1.0.0-rc.1`, `0.1.0-pre01`, `1.0.0-beta+meta`. - Must be strictly greater than the package's - current on-disk version per SemVer 2.0 ordering - (so e.g. `1.0.0-rc.1` < `1.0.0`). - - Each release decision is a judgment call: the author must review the - actual diff being released (source + dependency edits) and decide - whether the cumulative change is a breaking SemVer change, a backward- - compatible addition, a pure internal patch, or an explicit version pin. - Picking too weak a change type causes dependents to silently get - incompatible behaviour after `cargo update`; picking too strong is - harmless except it forces direct dependents to bump as well. - -.PARAMETER Changed - Switch: walk through every workspace package that has unreleased - modifications (changes newer than its last `version =` / `publish =` - commit) and prompt for a per-package release decision. Mutually - exclusive with -Packages and -All. - - The change scan only sees files under `crates//`; it cannot - detect impactful changes elsewhere in the repository (e.g. the - workspace-level `Cargo.toml`, `.cargo/`, `deny.toml`, or shared CI - configuration). If a cross-cutting change matters, use -All instead or - pass the affected packages explicitly via -Packages. - -.PARAMETER All - Switch: walk through every publishable workspace package, even ones - with no on-disk modifications. Use to force-walk the workspace when you - need a coordinated multi-package release plan or when a refactor might - have touched packages the modification scan misses. Mutually exclusive - with -Packages and -Changed. - -.EXAMPLE - # Release 'bytesbuf_io' as a breaking change. Cascade is automatic. - ./scripts/release-packages.ps1 -Packages 'bytesbuf_io@breaking' - -.EXAMPLE - # Release 'bytesbuf' and 'http_extensions' in a single transaction: - # bytesbuf as breaking, http_extensions as non-breaking. Any cascade - # between them or onto their dependents is computed automatically. - ./scripts/release-packages.ps1 -Packages 'bytesbuf@breaking','http_extensions@nonbreaking' - -.PARAMETER Force - Switch (valid only with -Packages): relax the explicit-version-pin - rejection. By default, if a cascade computation requires a higher - version than an explicit `@..` pin - allows, the release plan is rejected (the script refuses to - silently override an explicit pin). With -Force, the explicit pin - is honored verbatim, and the package's EffectiveChangeType tag is - upgraded to record the stronger unmet requirement for warnings and - bookkeeping. Exposure propagation continues past the forced pin: the - pin lowers the version number, not the incompatibility of the API - being shipped, so dependents exposing the crate still inherit the - break. A warning is printed flagging that consumers may break. - - -Force does NOT relax the always-fatal "pin is not strictly greater - than the current on-disk version" check, and has no effect on - change-type tokens (which are always auto-upgraded silently). - - -Force is not exposed in -Changed or -All mode: those modes only - accept change-type answers (breaking / non-breaking / patch) and - never explicit version pins, so the pin-vs-cascade rejection - cannot fire there. - -.EXAMPLE - # Pin a specific version, e.g. release 'my-package' as 1.0.0. - ./scripts/release-packages.ps1 -Packages 'my-package@1.0.0' - -.EXAMPLE - # Pin a pre-release version. - ./scripts/release-packages.ps1 -Packages 'my-package@1.0.0-rc.1' - -.EXAMPLE - # Force-honor a pin even when cascade analysis requires a higher version - # (consumers may break — use with caution). - ./scripts/release-packages.ps1 -Packages 'my-package@1.0.0' -Force - -.EXAMPLE - # Guided walk through every workspace package with unreleased modifications. - ./scripts/release-packages.ps1 -Changed - -.EXAMPLE - # Guided walk through every publishable workspace package. - ./scripts/release-packages.ps1 -All -#> -[CmdletBinding(DefaultParameterSetName = 'ByPackages')] -param( - [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'ByPackages')] - [ValidateNotNull()] - [string[]]$Packages, - - [Parameter(Mandatory = $true, ParameterSetName = 'Changed')] - [switch]$Changed, - - [Parameter(Mandatory = $true, ParameterSetName = 'All')] - [switch]$All, - - [Parameter(ParameterSetName = 'ByPackages')] - [switch]$Force -) - -# All helpers, configuration, and Invoke-ReleasePackagesMain live in the -# library so this script stays a thin CLI shell. The library also dot-sources -# scripts/lib/releasing.ps1 transitively, so consumers only need this one -# import. -. "$PSScriptRoot/lib/release-flow.ps1" - -$mode = switch ($PSCmdlet.ParameterSetName) { - 'ByPackages' { 'targeted' } - 'Changed' { 'changed' } - 'All' { 'all' } -} - -# Invoke-ReleasePackagesMain surfaces all validation / pre-flight / execution -# failures as terminating errors (throw) so it stays testable in-process. This -# thin CLI shell is the only layer that turns a failure into a process exit -# code, preserving the historical `exit 1`-on-error contract for command-line -# and CI callers. -try { - Invoke-ReleasePackagesMain -Mode $mode -Packages $Packages -Force:$Force | Out-Null -} catch { - Write-Error $_.Exception.Message - exit 1 -} diff --git a/scripts/tests/Pester/_common/Invoke-Scenario.ps1 b/scripts/tests/Pester/_common/Invoke-Scenario.ps1 deleted file mode 100644 index 9ed88768f..000000000 --- a/scripts/tests/Pester/_common/Invoke-Scenario.ps1 +++ /dev/null @@ -1,210 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -<# -.SYNOPSIS - End-to-end scenario runner for release-script Pester tests. - -.DESCRIPTION - Loads a PSD1 scenario descriptor, builds a synthetic Cargo workspace, - replays a history of operations, then invokes Invoke-ReleasePackagesMain - in-process with mocked Read-Host / Invoke-WorkspaceCheck / - Test-InteractiveSession. Returns a result object the test can assert on. - - The runner is invoked from Pester It blocks so Mock works correctly. - The caller is responsible for dot-sourcing scripts/lib/release-flow.ps1 - in a BeforeAll so this runner can refer to Invoke-ReleasePackagesMain and - Read-Host as known commands. - -.PARAMETER ScenarioFile - Absolute path to a .scenario.psd1 file describing the scenario. - -.PARAMETER WorkspaceRoot - Optional override for where the synthetic workspace is built (defaults - to a folder derived from the scenario name under $TestDrive when called - from a Pester test). -#> - -function Invoke-Scenario { - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)][string]$ScenarioFile, - [string]$WorkspaceRoot - ) - - if (-not (Test-Path $ScenarioFile)) { - throw "Scenario file not found: $ScenarioFile" - } - - $scenario = Import-PowerShellDataFile -Path $ScenarioFile - if (-not $scenario.Name) { throw "Scenario '$ScenarioFile' missing Name." } - if (-not $scenario.Workspace) { throw "Scenario '$ScenarioFile' missing Workspace." } - if (-not $scenario.Run) { throw "Scenario '$ScenarioFile' missing Run." } - - if (-not $WorkspaceRoot) { - $WorkspaceRoot = Join-Path $TestDrive ("scn-" + $scenario.Name) - } - - # --- 1. Build the workspace. - Reset-ReleaseScriptCaches - $wsParams = @{ Path = $WorkspaceRoot } - if ($scenario.Workspace.Preset) { $wsParams.Preset = $scenario.Workspace.Preset } - if ($scenario.Workspace.Spec) { $wsParams.Spec = $scenario.Workspace.Spec } - $ws = New-SyntheticWorkspace @wsParams - - # --- 2. Replay history. - foreach ($step in @($scenario.History)) { - if (-not $step.Op) { throw "Scenario '$($scenario.Name)' has a history step with no Op." } - switch ($step.Op) { - 'ModifySource' { $ws.ModifySource($step.Package) } - 'SetVersion' { $ws.SetVersion($step.Package, $step.To) } - 'SetPublishFalse' { $ws.SetPublishFalse($step.Package) } - 'AddCommit' { $ws.AddCommit($step.Message) } - 'Commit' { $ws.AddCommit($step.Message) } - 'EditCargoToml' { - # Generic raw text patch on a package's Cargo.toml. - $cargo = Join-Path $ws.Path "crates\$($step.Package)\Cargo.toml" - $content = Get-Content $cargo -Raw - $content = $content -replace $step.Pattern, $step.Replacement - Set-Content $cargo -Value $content -NoNewline - } - default { throw "Scenario '$($scenario.Name)' has unknown history Op '$($step.Op)'." } - } - } - - # --- 3. Set up answer queue and prompt-capture state in script scope so - # the mock script blocks can mutate them. - $script:ScenarioAnswerQueue = New-Object System.Collections.Queue - foreach ($a in @($scenario.Run.Answers)) { - $script:ScenarioAnswerQueue.Enqueue($a) - } - $script:ScenarioPromptsRaised = New-Object System.Collections.Generic.List[string] - $script:ScenarioRepliesGiven = New-Object System.Collections.Generic.List[string] - $script:ScenarioSkippedPromptFolders = New-Object System.Collections.Generic.List[string] - - # Simulated cargo-semver-checks verdicts (folder -> 'breaking'|'non-breaking'| - # 'patch'|'none'), consumed by the Get-CrateRequiredChangeType mock the test - # installs. Absent entries default to 'none' (no constraint) — the cascade - # then floors dependents at 'patch'. Scenarios that need a stronger cascade - # (e.g. a dependent whose own API broke) declare Run.SemverVerdicts. - $script:ScenarioSemverVerdicts = @{} - if ($scenario.Run.SemverVerdicts) { - foreach ($k in $scenario.Run.SemverVerdicts.Keys) { - $script:ScenarioSemverVerdicts[$k] = $scenario.Run.SemverVerdicts[$k] - } - } - - # --- 4. Invoke under mocks. The caller (test) has already mocked the - # script-level cmdlets by the time this runs. We only invoke the entry - # point and capture the release records + any thrown exception. - Push-Location $ws.Path - try { - # The scenario harness wires a single entry point — Invoke-ReleasePackagesMain — - # and selects between its three -Mode values from the scenario PSD1: - # - # Run.Mode = 'changed' → Invoke-ReleasePackagesMain -Mode 'changed' - # (interactive guided walk through every modified package; no - # -Packages tokens). - # - # Run.Mode = 'all' → Invoke-ReleasePackagesMain -Mode 'all' - # (interactive guided walk through every publishable package, even - # ones with no on-disk modifications). - # - # otherwise (default) → Invoke-ReleasePackagesMain -Mode 'targeted' - # with explicit -Packages tokens (the historical scenario style). - $error.Clear() - $caught = $null - - $runMode = if ($scenario.Run.Mode) { $scenario.Run.Mode } else { 'targeted' } - # Run.Force is only meaningful in targeted mode — production rejects - # `-Force` for changed/all because those modes don't accept explicit - # version pins, so the pin-vs-cascade rejection that -Force overrides - # cannot fire. Mirror that contract here so scenario PSD1s can't - # accidentally exercise a code path production also rejects. - $useForce = [bool]$scenario.Run.Force - if ($useForce -and $runMode -ne 'targeted') { - throw "Scenario '$($scenario.Name)' sets Run.Force but Run.Mode='$runMode'; -Force is only valid in targeted mode (it overrides the pin-vs-cascade rejection that only applies to explicit version pins)." - } - if ($runMode -in @('changed', 'all')) { - if ($null -ne $scenario.Run.Packages -or $null -ne $scenario.Run.PackageName) { - throw "Scenario '$($scenario.Name)' uses Run.Mode='$runMode' but also sets Run.Packages/Run.PackageName; choose one." - } - try { - $releases = Invoke-ReleasePackagesMain -Mode $runMode 6> $null - } catch { - $caught = $_ - $releases = @() - } - } elseif ($runMode -eq 'targeted') { - # New-style scenarios provide Run.Packages directly (a string[] of - # '@' tokens). Legacy scenarios provided - # Run.PackageName + Run.Change/Run.Version + Run.BaseRef; translate - # them on the fly so the scenario PSD1s can migrate independently. - $packageTokens = $null - if ($null -ne $scenario.Run.Packages -and @($scenario.Run.Packages).Count -gt 0) { - $packageTokens = @($scenario.Run.Packages) - } else { - if (-not $scenario.Run.PackageName) { - throw "Scenario '$($scenario.Name)' must provide either Run.Mode='changed'/'all', Run.Packages, or Run.PackageName." - } - $changeSpec = if ($scenario.Run.Version) { - $scenario.Run.Version - } elseif ($scenario.Run.Change) { - switch ($scenario.Run.Change) { - 'Breaking' { 'breaking' } - 'NonBreaking' { 'nonbreaking' } - 'Patch' { 'patch' } - '1.0' { '1.0.0' } - default { throw "Scenario '$($scenario.Name)' has unrecognised Run.Change '$($scenario.Run.Change)'." } - } - } else { - # Default change type for bare invocations. - 'nonbreaking' - } - $packageTokens = @("$($scenario.Run.PackageName)@$changeSpec") - } - - try { - $invokeArgs = @{ Mode = 'targeted'; Packages = $packageTokens } - if ($useForce) { $invokeArgs.Force = $true } - $releases = Invoke-ReleasePackagesMain @invokeArgs 6> $null - } catch { - $caught = $_ - $releases = @() - } - } else { - throw "Scenario '$($scenario.Name)' has unknown Run.Mode '$runMode'. Expected 'targeted', 'changed', or 'all'." - } - } finally { - Pop-Location - } - - return [pscustomobject]@{ - Scenario = $scenario - Workspace = $ws - Releases = @($releases) - PromptsRaised = $script:ScenarioPromptsRaised.ToArray() - RepliesGiven = $script:ScenarioRepliesGiven.ToArray() - SkippedPrompts = $script:ScenarioSkippedPromptFolders.ToArray() - UnconsumedAnswers = @($script:ScenarioAnswerQueue.ToArray()) - Error = $caught - } -} - -# Helper invoked from the Read-Host mock so the answer-matching logic lives in -# one place. Returns the reply string and records the prompt. -function Resolve-ScenarioPromptReply { - [CmdletBinding()] - param([Parameter(Mandatory = $true)][string]$Prompt) - - $script:ScenarioPromptsRaised.Add($Prompt) | Out-Null - if ($script:ScenarioAnswerQueue.Count -eq 0) { - throw "Scenario answer queue is empty but prompt arrived: '$Prompt'" - } - $next = $script:ScenarioAnswerQueue.Dequeue() - if ($next.Match -and ($Prompt -notmatch [regex]::Escape($next.Match))) { - throw "Scenario answer mismatch.`n Expected to match: $($next.Match)`n Got prompt: $Prompt" - } - $script:ScenarioRepliesGiven.Add($next.Reply) | Out-Null - return $next.Reply -} diff --git a/scripts/tests/Pester/_common/New-SyntheticWorkspace.ps1 b/scripts/tests/Pester/_common/New-SyntheticWorkspace.ps1 index f72916193..231c4b246 100644 --- a/scripts/tests/Pester/_common/New-SyntheticWorkspace.ps1 +++ b/scripts/tests/Pester/_common/New-SyntheticWorkspace.ps1 @@ -187,6 +187,12 @@ function Write-PackageCargoToml { $entries = ($Package.AllowedExternalTypes | ForEach-Object { "`"$_`"" }) -join ', ' $lines += "allowed_external_types = [$entries]" } + if ($Package.ContainsKey('MacroRuntime') -and $null -ne $Package.MacroRuntime) { + $lines += '' + $lines += '[package.metadata.oxidizer_release]' + $entries = ($Package.MacroRuntime | ForEach-Object { "`"$_`"" }) -join ', ' + $lines += "macro_runtime = [$entries]" + } if ($Package.ContainsKey('ProcMacro') -and $Package.ProcMacro) { $lines += '' $lines += '[lib]' @@ -244,12 +250,25 @@ function Write-PackageCargoToml { # it cannot express an alias without also rewriting that table. Path-only is # sufficient here because these fixtures are read via `cargo metadata` and never # packaged. +# +# A dep carrying `External = $true` names a registry crate rather than a +# workspace member. It inherits from [workspace.dependencies] unless it also +# carries `Version`, which pins it inline in the member manifest. function Format-DependencyLine { param([Parameter(Mandatory = $true)][hashtable]$Dep) if ($Dep.ContainsKey('Rename') -and -not [string]::IsNullOrWhiteSpace($Dep.Rename)) { + if ($Dep.ContainsKey('External') -and $Dep.External) { + return "$($Dep.Rename) = { package = `"$($Dep.Name)`", version = `"$($Dep.Version)`" }" + } return "$($Dep.Rename) = { package = `"$($Dep.Name)`", path = `"../$($Dep.Name)`" }" } + if ( + $Dep.ContainsKey('External') -and $Dep.External -and + $Dep.ContainsKey('Version') -and -not [string]::IsNullOrWhiteSpace($Dep.Version) + ) { + return "$($Dep.Name) = { version = `"$($Dep.Version)`" }" + } return "$($Dep.Name).workspace = true" } @@ -280,6 +299,13 @@ function Write-RootCargoToml { foreach ($package in $Spec.Packages) { $lines += "$($package.Name) = { path = `"crates/$($package.Name)`", version = `"$($package.Version)`" }" } + # Registry crates the members may inherit. Keyed by crate name, valued by + # requirement string, e.g. @{ syn = '2.0.111' }. + if ($Spec.ContainsKey('ExternalDependencies') -and $null -ne $Spec.ExternalDependencies) { + foreach ($name in @($Spec.ExternalDependencies.Keys | Sort-Object)) { + $lines += "$name = { version = `"$($Spec.ExternalDependencies[$name])`" }" + } + } Set-Content -Path $Path -Value ($lines -join "`n") -NoNewline } @@ -401,6 +427,30 @@ function New-SyntheticWorkspace { Set-Content -Path $packagePath -Value $content -NoNewline } + $ws | Add-Member -MemberType ScriptMethod -Name 'SetWorkspaceDependencyVersion' -Value { + param([string]$Name, [string]$NewVersion) + $rootPath = Join-Path $this.Path 'Cargo.toml' + $content = Get-Content $rootPath -Raw + $pattern = "(?m)^$([regex]::Escape($Name))\s*=\s*\{\s*version\s*=\s*`"[^`"]+`"\s*\}" + if ($content -notmatch $pattern) { + throw "SetWorkspaceDependencyVersion: '$Name' is not a workspace dependency." + } + $content = [regex]::Replace($content, $pattern, "$Name = { version = `"$NewVersion`" }") + Set-Content -Path $rootPath -Value $content -NoNewline + } + + $ws | Add-Member -MemberType ScriptMethod -Name 'SetPackageDependencyVersion' -Value { + param([string]$Package, [string]$Name, [string]$NewVersion) + $packagePath = Join-Path $this.Path "crates\$Package\Cargo.toml" + $content = Get-Content $packagePath -Raw + $pattern = "(?m)^$([regex]::Escape($Name))\s*=\s*\{\s*version\s*=\s*`"[^`"]+`"\s*\}" + if ($content -notmatch $pattern) { + throw "SetPackageDependencyVersion: '$Name' is not an inline dependency of '$Package'." + } + $content = [regex]::Replace($content, $pattern, "$Name = { version = `"$NewVersion`" }") + Set-Content -Path $packagePath -Value $content -NoNewline + } + $ws | Add-Member -MemberType ScriptMethod -Name 'AddCommit' -Value { param([string]$Message) Push-Location $this.Path diff --git a/scripts/tests/Pester/_common/TestHelpers.ps1 b/scripts/tests/Pester/_common/TestHelpers.ps1 index b5b0ec7f9..e187502fb 100644 --- a/scripts/tests/Pester/_common/TestHelpers.ps1 +++ b/scripts/tests/Pester/_common/TestHelpers.ps1 @@ -7,8 +7,7 @@ .DESCRIPTION Provides Get-OxiRepoRoot for deterministic path resolution from any - test file. Test files dot-source the shared script libraries - (scripts/lib/release-flow.ps1 etc.) directly using + test file. Test files dot-source the shared script libraries directly using Join-Path (Get-OxiRepoRoot) 'scripts\lib\.ps1'. Also provides Get-BytesBufIoAllowlist, the one canonical copy of a real diff --git a/scripts/tests/Pester/integration/DependencyRename.Tests.ps1 b/scripts/tests/Pester/integration/DependencyRename.Tests.ps1 index 241b95cd9..4366cf2e4 100644 --- a/scripts/tests/Pester/integration/DependencyRename.Tests.ps1 +++ b/scripts/tests/Pester/integration/DependencyRename.Tests.ps1 @@ -1,328 +1,117 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# Covers the exposure paths where a dependency's crate root differs from its -# package name, end-to-end from a real Cargo manifest through `cargo metadata` -# to the cascade decision. Two constructs do this: `package = "..."` on the -# dependency (rename) and `[lib] name = "..."` in the dependency's own manifest. -# -# The unit tests for this construct DepAliases by hand, already normalized, so -# they prove the matching logic but not the extraction that feeds it. A -# regression in reading `dependency.rename` or the lib target name, or in -# converting `aliased-dep` to `aliased_dep`, would restore the original -# fail-open with every unit test still green. These tests close that gap by -# building actual workspaces and loading them through Get-WorkspacePackages. - BeforeAll { . (Join-Path $PSScriptRoot '..\_common\TestHelpers.ps1') . (Join-Path $PSScriptRoot '..\_common\New-SyntheticWorkspace.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') - - # dependent depends on `dependency` but declares it under the alias - # `aliased-dep`, so Rust source -- and therefore the allowlist -- can only - # name it as `aliased_dep`. - function New-RenamedDepWorkspace { - param( - [Parameter(Mandatory = $true)][string]$Path, - [string[]]$AllowedExternalTypes = @('aliased_dep::Handle') + . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\releasing.ps1') + + $script:FactsScript = Join-Path ( + Get-OxiRepoRoot + ) '.github\skills\release-packages\scripts\release-facts.ps1' + + $spec = @{ + Packages = @( + @{ Name = 'dependency'; Version = '1.0.0'; LibName = 'dep_core' } + @{ + Name = 'renamed_consumer' + Version = '1.0.0' + Deps = @(@{ Name = 'dependency'; Rename = 'aliased-dep' }) + AllowedExternalTypes = @('aliased_dep::Handle') + } + @{ + Name = 'lib_consumer' + Version = '1.0.0' + Deps = @(@{ Name = 'dependency' }) + AllowedExternalTypes = @('dep_core::Handle') + } + @{ + Name = 'shadowed_consumer' + Version = '1.0.0' + Deps = @(@{ Name = 'dependency'; Rename = 'aliased-dep' }) + AllowedExternalTypes = @('dep_core::Handle') + } + @{ + Name = 'relay' + Version = '1.0.0' + Deps = @(@{ Name = 'dependency' }) + AllowedExternalTypes = @() + } + @{ + Name = 'facade' + Version = '1.0.0' + Deps = @(@{ Name = 'relay' }) + AllowedExternalTypes = @('dep_core::Handle') + } ) - $spec = @{ - Packages = @( - @{ - Name = 'dependent'; Version = '1.0.0' - Deps = @(@{ Name = 'dependency'; Rename = 'aliased-dep' }) - AllowedExternalTypes = $AllowedExternalTypes - } - @{ Name = 'dependency'; Version = '1.0.0' } - ) - } - return New-SyntheticWorkspace -Spec $spec -Path $Path } - # `dependency` renames its own crate root with `[lib] name = "dep_core"`. - # The package is still depended on as `dependency`, but Rust source -- and - # therefore the allowlist -- can only name it as `dep_core`. - function New-LibNameWorkspace { - param( - [Parameter(Mandatory = $true)][string]$Path, - [string[]]$AllowedExternalTypes = @('dep_core::Handle'), - [string]$Rename = $null - ) - $dep = @{ Name = 'dependency' } - if (-not [string]::IsNullOrWhiteSpace($Rename)) { $dep['Rename'] = $Rename } - $spec = @{ - Packages = @( - @{ - Name = 'dependent'; Version = '1.0.0' - Deps = @($dep) - AllowedExternalTypes = $AllowedExternalTypes - } - @{ Name = 'dependency'; Version = '1.0.0'; LibName = 'dep_core' } - ) - } - return New-SyntheticWorkspace -Spec $spec -Path $Path - } - # defining -> relay -> facade, where defining's crate root is `def_core` - # and facade reaches a def_core type re-exported through relay. facade - # declares no edge to defining, so nothing on any edge it owns can tell it - # what defining's crate root is -- the root has to come from the target. - function New-IndirectLibNameWorkspace { - param( - [Parameter(Mandatory = $true)][string]$Path, - [string[]]$FacadeAllowedExternalTypes = @('def_core::Handle') - ) - $spec = @{ - Packages = @( - @{ - Name = 'facade'; Version = '1.0.0' - Deps = @(@{ Name = 'relay' }) - AllowedExternalTypes = $FacadeAllowedExternalTypes - } - @{ - Name = 'relay'; Version = '1.0.0' - Deps = @(@{ Name = 'defining' }) - AllowedExternalTypes = @() - } - @{ Name = 'defining'; Version = '1.0.0'; LibName = 'def_core'; AllowedExternalTypes = @() } - ) - } - return New-SyntheticWorkspace -Spec $spec -Path $Path + $script:Workspace = New-SyntheticWorkspace ` + -Spec $spec ` + -Path (Join-Path $TestDrive 'dependency-roots') + Reset-ReleaseScriptCaches + $script:Packages = @(Get-WorkspacePackages -repoRoot $script:Workspace.Path) + $script:Facts = & $script:FactsScript -RepoRoot $script:Workspace.Path | + ConvertFrom-Json + $script:FactsByFolder = @{} + foreach ($fact in $script:Facts.packages) { + $script:FactsByFolder[$fact.folder] = $fact } } -Describe 'Renamed dependency exposure (via cargo metadata)' { - BeforeEach { - Reset-ReleaseScriptCaches - } - - It 'records the rename alias from cargo metadata, normalized to underscores' { - $ws = New-RenamedDepWorkspace -Path (Join-Path $TestDrive 'rename-extract') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependent = $pkgs | Where-Object { $_.Folder -eq 'dependent' } - - # The edge is keyed by the REAL package name; only the alias is extra. - $dependent.Deps | Should -Contain 'dependency' - $dependent.DepAliases.ContainsKey('dependency') | Should -BeTrue - # 'aliased-dep' in the manifest, 'aliased_dep' in Rust paths. - @($dependent.DepAliases['dependency']) | Should -Contain 'aliased_dep' - } - - It 'leaves DepAliases empty for a package with no renamed dependencies' { - $ws = New-RenamedDepWorkspace -Path (Join-Path $TestDrive 'rename-none') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependency = $pkgs | Where-Object { $_.Folder -eq 'dependency' } - - $dependency.DepAliases.Count | Should -Be 0 - } - - It 'reports exposure for an allowlist entry rooted at the alias' { - $ws = New-RenamedDepWorkspace -Path (Join-Path $TestDrive 'rename-exposes') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependent = $pkgs | Where-Object { $_.Folder -eq 'dependent' } - - Test-PackageExposesTarget -Dependent $dependent -TargetPackageName 'dependency' | Should -BeTrue - } - - It 'reports no exposure when the allowlist names neither the alias nor the real name' { - # Negative control: proves the test above passes because of the alias - # and not because something upstream fails closed. - $ws = New-RenamedDepWorkspace -Path (Join-Path $TestDrive 'rename-unrelated') ` - -AllowedExternalTypes @('unrelated_crate::Handle') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependent = $pkgs | Where-Object { $_.Folder -eq 'dependent' } - - Test-PackageExposesTarget -Dependent $dependent -TargetPackageName 'dependency' | Should -BeFalse - } - - It 'cascades a breaking dependency through the aliased exposure edge' { - $ws = New-RenamedDepWorkspace -Path (Join-Path $TestDrive 'rename-cascade') - $baseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $stub = { param([string]$Folder, [string]$CargoName) 'none' } - - $resolved = Resolve-ReleaseSet ` - -ParsedTokens (Parse-ReleaseTokens -Tokens @('dependency@breaking')) ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $stub - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'breaking' - $dependent.EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'does not cascade when the aliased dependency is absent from the allowlist' { - $ws = New-RenamedDepWorkspace -Path (Join-Path $TestDrive 'rename-nocascade') ` - -AllowedExternalTypes @('unrelated_crate::Handle') - $baseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $stub = { param([string]$Folder, [string]$CargoName) 'none' } - - $resolved = Resolve-ReleaseSet ` - -ParsedTokens (Parse-ReleaseTokens -Tokens @('dependency@breaking')) ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $stub - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'patch' - $dependent.EffectiveTargetVersion | Should -Be '1.0.1' - } +AfterAll { + Get-ChildItem ` + -LiteralPath (Join-Path $script:Workspace.Path '.git') ` + -File ` + -Recurse ` + -Force | + ForEach-Object { $_.IsReadOnly = $false } } -Describe 'Renamed crate root via [lib] name (via cargo metadata)' { - BeforeEach { - Reset-ReleaseScriptCaches - } - - It 'records the dependency''s lib target name as an alias' { - $ws = New-LibNameWorkspace -Path (Join-Path $TestDrive 'libname-extract') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependent = $pkgs | Where-Object { $_.Folder -eq 'dependent' } +Describe 'Dependency crate-root extraction' { + It 'records a package rename under the real dependency name' { + $consumer = $script:Packages | + Where-Object Folder -eq 'renamed_consumer' - # The edge is still keyed by the package name; the crate root is extra. - $dependent.Deps | Should -Contain 'dependency' - $dependent.DepAliases.ContainsKey('dependency') | Should -BeTrue - @($dependent.DepAliases['dependency']) | Should -Contain 'dep_core' + $consumer.Deps | Should -Contain 'dependency' + @($consumer.DepAliases['dependency']) | Should -Contain 'aliased_dep' } - It 'records no alias when the lib target name matches the package name' { - # Negative control for the extraction: the alias must come from a real - # divergence, not be manufactured for every dependency. - $ws = New-RenamedDepWorkspace -Path (Join-Path $TestDrive 'libname-matching') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependency = $pkgs | Where-Object { $_.Folder -eq 'dependency' } + It 'records the dependency library target name when no edge rename exists' { + $consumer = $script:Packages | + Where-Object Folder -eq 'lib_consumer' - $dependency.DepAliases.Count | Should -Be 0 + @($consumer.DepAliases['dependency']) | Should -Contain 'dep_core' } - It 'reports exposure for an allowlist entry rooted at the lib name' { - $ws = New-LibNameWorkspace -Path (Join-Path $TestDrive 'libname-exposes') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependent = $pkgs | Where-Object { $_.Folder -eq 'dependent' } + It 'records each package own crate root' { + $dependency = $script:Packages | + Where-Object Folder -eq 'dependency' - Test-PackageExposesTarget -Dependent $dependent -TargetPackageName 'dependency' | Should -BeTrue - } - - It 'reports no exposure when the allowlist names neither the lib name nor the package' { - $ws = New-LibNameWorkspace -Path (Join-Path $TestDrive 'libname-unrelated') ` - -AllowedExternalTypes @('unrelated_crate::Handle') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependent = $pkgs | Where-Object { $_.Folder -eq 'dependent' } - - Test-PackageExposesTarget -Dependent $dependent -TargetPackageName 'dependency' | Should -BeFalse - } - - It 'prefers the rename over the lib name when the dependency declares both' { - # `aliased-dep = { package = "dependency" }` against a dependency whose - # lib target is `dep_core`. Rust names it `aliased_dep`: the rename is - # applied to the dependency edge, so the lib name never surfaces in the - # consumer. Recording `dep_core` here would be inert at best and, for a - # crate that allowlists `dep_core` through some *other* path, a false - # exposure on this edge. - $ws = New-LibNameWorkspace -Path (Join-Path $TestDrive 'libname-rename-wins') ` - -Rename 'aliased-dep' -AllowedExternalTypes @('aliased_dep::Handle') - $pkgs = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependent = $pkgs | Where-Object { $_.Folder -eq 'dependent' } - - @($dependent.DepAliases['dependency']) | Should -Contain 'aliased_dep' - @($dependent.DepAliases['dependency']) | Should -Not -Contain 'dep_core' - Test-PackageExposesTarget -Dependent $dependent -TargetPackageName 'dependency' | Should -BeTrue - } - - It 'does not accept the lib name on an edge where a rename shadows it' { - # The other half of the precedence rule, and the half that actually - # bites. The test above pins what DepAliases *contains*; this one pins - # what the predicate *does*, which is what a regression breaks. A - # dependent importing the crate as `aliased_dep` cannot write - # `dep_core::Handle` -- the rename shadows the lib name completely -- so - # such an entry must be some unrelated crate and must not count as - # exposure here. Reintroducing the target's global crate root on a - # declared edge turns that collision into a spurious breaking bump. - $ws = New-LibNameWorkspace -Path (Join-Path $TestDrive 'libname-rename-shadows') ` - -Rename 'aliased-dep' -AllowedExternalTypes @('dep_core::Handle') - $baseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $dependent = $baseline | Where-Object { $_.Folder -eq 'dependent' } - - Test-PackageExposesTarget -Dependent $dependent -TargetPackageName 'dependency' | Should -BeFalse - - $stub = { param([string]$Folder, [string]$CargoName) 'none' } - $resolved = Resolve-ReleaseSet ` - -ParsedTokens (Parse-ReleaseTokens -Tokens @('dependency@breaking')) ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $stub - $resolvedDependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $resolvedDependent.EffectiveChangeType | Should -Be 'patch' - $resolvedDependent.EffectiveTargetVersion | Should -Be '1.0.1' - } - - It 'cascades a breaking dependency through the lib-name exposure edge' { - $ws = New-LibNameWorkspace -Path (Join-Path $TestDrive 'libname-cascade') - $baseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $stub = { param([string]$Folder, [string]$CargoName) 'none' } - - $resolved = Resolve-ReleaseSet ` - -ParsedTokens (Parse-ReleaseTokens -Tokens @('dependency@breaking')) ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $stub - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'breaking' - $dependent.EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'does not cascade when the lib-name root is absent from the allowlist' { - $ws = New-LibNameWorkspace -Path (Join-Path $TestDrive 'libname-nocascade') ` - -AllowedExternalTypes @('unrelated_crate::Handle') - $baseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $stub = { param([string]$Folder, [string]$CargoName) 'none' } - - $resolved = Resolve-ReleaseSet ` - -ParsedTokens (Parse-ReleaseTokens -Tokens @('dependency@breaking')) ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $stub - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'patch' - $dependent.EffectiveTargetVersion | Should -Be '1.0.1' + $dependency.CrateRoot | Should -Be 'dep_core' } } -Describe 'Indirect exposure of a diverted crate root (via cargo metadata)' { - BeforeEach { - Reset-ReleaseScriptCaches +Describe 'Release facts for diverted crate roots' { + It 'recognizes a direct exposure through a package rename' { + @($script:FactsByFolder['renamed_consumer'].exposedDeps) | + Should -Contain 'dependency' } - It 'cascades to an indirect dependent whose allowlist is rooted at the lib name' { - $ws = New-IndirectLibNameWorkspace -Path (Join-Path $TestDrive 'indirect-libname-cascade') - $baseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $stub = { param([string]$Folder, [string]$CargoName) 'none' } - - $resolved = Resolve-ReleaseSet ` - -ParsedTokens (Parse-ReleaseTokens -Tokens @('defining@breaking')) ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $stub - $facade = $resolved | Where-Object { $_.Folder -eq 'facade' } - - $facade.EffectiveChangeType | Should -Be 'breaking' - $facade.EffectiveTargetVersion | Should -Be '2.0.0' - # relay claims to expose nothing, so it correctly stays at its floor -- - # facade is reached on its own allowlist evidence, not through relay. - ($resolved | Where-Object { $_.Folder -eq 'relay' }).EffectiveChangeType | Should -Be 'patch' + It 'recognizes a direct exposure through a custom library target name' { + @($script:FactsByFolder['lib_consumer'].exposedDeps) | + Should -Contain 'dependency' } - It 'does not cascade to an indirect dependent that names an unrelated root' { - # Negative control: proves the test above passes because the allowlist - # names defining's crate root, not because the indirect branch fails open. - $ws = New-IndirectLibNameWorkspace -Path (Join-Path $TestDrive 'indirect-libname-nocascade') ` - -FacadeAllowedExternalTypes @('unrelated_crate::Handle') - $baseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $stub = { param([string]$Folder, [string]$CargoName) 'none' } + It 'does not use the library target name when an edge rename shadows it' { + @($script:FactsByFolder['shadowed_consumer'].exposedDeps) | + Should -Not -Contain 'dependency' + } - $resolved = Resolve-ReleaseSet ` - -ParsedTokens (Parse-ReleaseTokens -Tokens @('defining@breaking')) ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $stub - $facade = $resolved | Where-Object { $_.Folder -eq 'facade' } + It 'recognizes an indirect re-export by the defining crate root' { + $facade = $script:FactsByFolder['facade'] - $facade.EffectiveChangeType | Should -Be 'patch' - $facade.EffectiveTargetVersion | Should -Be '1.0.1' + @($facade.deps) | Should -Not -Contain 'dependency' + @($facade.exposedDeps) | Should -Contain 'dependency' } } diff --git a/scripts/tests/Pester/integration/ExposureCascade-RealWorkspace.Tests.ps1 b/scripts/tests/Pester/integration/ExposureCascade-RealWorkspace.Tests.ps1 index bbd4b31d0..35f576731 100644 --- a/scripts/tests/Pester/integration/ExposureCascade-RealWorkspace.Tests.ps1 +++ b/scripts/tests/Pester/integration/ExposureCascade-RealWorkspace.Tests.ps1 @@ -1,225 +1,294 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# End-to-end regression coverage for the exposed-dependency cascade, run -# against the LIVE workspace rather than synthetic package records. -# -# Every other test of this logic builds its own [pscustomobject] baseline, so -# the planner is only ever proven correct about graphs the test itself -# invented. That leaves the real failure mode unpinned: bytesbuf_io exposes -# bytesbuf types across its public API, so a breaking bytesbuf release must -# force a breaking bytesbuf_io release. Before this cascade existed, -# bytesbuf_io took a mechanical patch floor and shipped a silent break. -# -# These tests read the actual manifests. That coupling is deliberate: if the -# bytesbuf/bytesbuf_io relationship changes, or bytesbuf_io's allowlist is -# edited, this file must fail and be consciously updated. A snapshot fixture -# would re-create exactly the staleness problem it is meant to catch. - BeforeAll { . (Join-Path $PSScriptRoot '..\_common\TestHelpers.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') $script:RepoRoot = Get-OxiRepoRoot - # cargo metadata over the whole workspace is slow; resolve it once. - $script:LiveBaseline = @(Get-WorkspacePackages -repoRoot $script:RepoRoot) + $script:FactsScript = Join-Path ( + $script:RepoRoot + ) '.github\skills\release-packages\scripts\release-facts.ps1' + $script:Resolver = Join-Path ( + $script:RepoRoot + ) '.github\skills\release-packages\scripts\resolve-plan.ps1' - function Get-LivePackage { - param([string]$Folder) - return $script:LiveBaseline | Where-Object { $_.Folder -eq $Folder } + $script:Facts = & $script:FactsScript -RepoRoot $script:RepoRoot | + ConvertFrom-Json + $script:FactsByFolder = @{} + $script:FactsByName = @{} + foreach ($fact in $script:Facts.packages) { + $script:FactsByFolder[$fact.folder] = $fact + $script:FactsByName[$fact.name.Replace('-', '_')] = $fact } - # Keeps the planner off cargo-semver-checks: every crate reports 'none', so - # the only thing that can raise a version is the cascade under test. - $script:NoSelfChangeClassifier = { - param([string]$Folder, [string]$CargoName) - return 'none' - } -} + function Invoke-LivePlan { + param([Parameter(Mandatory = $true)][string]$Token) -Describe 'Exposed-dependency cascade over the live workspace' { - Context 'the bytesbuf -> bytesbuf_io topology' { - It 'still has both crates present and published' { - foreach ($folder in @('bytesbuf', 'bytesbuf_io')) { - $pkg = Get-LivePackage -Folder $folder - $pkg | Should -Not -BeNullOrEmpty -Because "crates/$folder underpins this regression test" - $pkg.Published | Should -BeTrue -Because "an unpublished $folder would drop out of the cascade entirely" + $caseDir = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $caseDir | Out-Null + $factsPath = Join-Path $caseDir 'facts.json' + $requestPath = Join-Path $caseDir 'request.json' + $factsForPlan = $script:Facts | + ConvertTo-Json -Depth 8 | + ConvertFrom-Json + foreach ($fact in $factsForPlan.packages) { + if ([bool]$fact.published) { + # CI checks out pull-request merge refs without tags. This live + # topology test exercises cascade mechanics, not tag discovery. + $fact.everReleased = $true } } - - It 'still records bytesbuf as a non-dev dependency of bytesbuf_io' { - # The edge itself. Without it the cascade never even considers the - # pair, and every assertion below would pass vacuously. - (Get-LivePackage -Folder 'bytesbuf_io').Deps | Should -Contain 'bytesbuf' + $factsForPlan | + ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $factsPath -Encoding utf8 + $classifications = @{} + foreach ($fact in $factsForPlan.packages) { + if ([bool]$fact.published) { + $classifications[$fact.folder] = 'patch' + } } + $allFolders = @($factsForPlan.packages.folder) + $macroContracts = @{} + foreach ($fact in $factsForPlan.packages) { + if (-not [bool]$fact.procMacroOnly) { continue } + # This test exercises cascade mechanics with macro contracts held + # constant, so every compile-fixture obligation the facts report is + # discharged with an unchanged outcome. Mirroring the two sides is + # what "held constant" means mechanically; a real review measures + # them instead. + $compileEvidence = @( + foreach ($obligation in @($fact.macroCompileFixtureChanges)) { + $result = if ($obligation.expectedResult -eq 'fail') { + 'fail' + } else { + 'pass' + } + $exitCode = if ($result -eq 'fail') { 101 } else { 0 } + @{ + ownerPackage = $obligation.ownerPackage + path = $obligation.path + baseline = @{ + result = $result + revision = $obligation.baselineRev + exitCode = $exitCode + } + current = @{ + result = $result + revision = 'HEAD' + exitCode = $exitCode + } + } + } + ) + $macroContracts[$fact.folder] = @{ + verdict = 'compatible' + reviewedPackages = $allFolders + channels = @{ + exportedMacros = 'unchanged' + acceptedSyntax = 'unchanged' + compileBehavior = 'unchanged' + generatedApi = 'unchanged' + generatedRuntimePaths = 'unchanged' + hygiene = 'unchanged' + } + evidence = @('Live topology tests hold macro contracts constant.') + compileEvidence = $compileEvidence + } + } + @{ + mode = 'targeted' + tokens = @($Token) + classifications = $classifications + macroContracts = $macroContracts + } | + ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $requestPath -Encoding utf8 - It 'still declares a bytesbuf-rooted entry in the real bytesbuf_io allowlist' { - # Pins the manifest against the literal asserted in - # PureFunctions.Tests.ps1. Deleting `bytesbuf::*` here is precisely - # how the fail-open would be reintroduced. - $allowed = @((Get-LivePackage -Folder 'bytesbuf_io').AllowedExternalTypes) - $allowed | Should -Not -BeNullOrEmpty -Because 'absent metadata would mask the real assertion behind the fail-closed branch' + & $script:Resolver ` + -FactsPath $factsPath ` + -RequestPath $requestPath | + ConvertFrom-Json + } +} - $roots = @($allowed | ForEach-Object { ($_ -split '::', 2)[0] }) - $roots | Should -Contain 'bytesbuf' - } +Describe 'Exposure cascades over the live workspace' { + It 'identifies bytesbuf exposure in bytesbuf_io facts' { + $bytesbufIo = $script:FactsByFolder['bytesbuf_io'] - It 'still matches every shared allowlist literal entry' { - # The unit tests assert exposure of `ohno` and `futures_core` too, - # using the same literal. Checking only the bytesbuf root would - # leave those two asserted against a copy nothing pins, so the unit - # tests could keep passing on entries the manifest had dropped. - # Compare the whole set, which is what makes the "copied verbatim" - # comment in _common a fact rather than an intention. - $allowed = @((Get-LivePackage -Folder 'bytesbuf_io').AllowedExternalTypes) - - # Order is irrelevant -- this pins the contents of the allowlist, - # not how the manifest happens to sort them. - ($allowed | Sort-Object) -join '|' | - Should -Be ((Get-BytesBufIoAllowlist | Sort-Object) -join '|') ` - -Because 'crates/bytesbuf_io/Cargo.toml and Get-BytesBufIoAllowlist must not drift apart' - } + @($bytesbufIo.deps) | Should -Contain 'bytesbuf' + @($bytesbufIo.exposedDeps) | Should -Contain 'bytesbuf' + } - It 'reports bytesbuf_io as exposing bytesbuf using the real package records' { - Test-PackageExposesTarget ` - -Dependent (Get-LivePackage -Folder 'bytesbuf_io') ` - -TargetPackageName 'bytesbuf' | Should -BeTrue - } + It 'raises bytesbuf_io to breaking for a breaking bytesbuf release' { + $plan = Invoke-LivePlan -Token 'bytesbuf@breaking' + $bytesbufIo = $plan.releases | + Where-Object folder -eq 'bytesbuf_io' + + $bytesbufIo.changeType | Should -Be 'breaking' + @($bytesbufIo.cascadeReasons | + Where-Object { $_.target -eq 'bytesbuf' -and $_.breaking }).Count | + Should -Be 1 } - Context 'planning a breaking bytesbuf release' { - BeforeAll { - $parsed = Parse-ReleaseTokens -Tokens @('bytesbuf@breaking') - $resolved = Resolve-ReleaseSet ` - -ParsedTokens $parsed ` - -WorkspaceBaseline $script:LiveBaseline ` - -GetRequiredChangeType $script:NoSelfChangeClassifier + It 'finds at least one indirect public exposure edge' { + $indirect = @( + foreach ($fact in $script:Facts.packages) { + foreach ($target in @($fact.exposedDeps)) { + if (@($fact.deps) -notcontains $target) { + [pscustomobject]@{ Dependent = $fact; Target = $target } + } + } + } + ) - $script:ByFolder = @{} - foreach ($entry in $resolved) { $script:ByFolder[$entry.Folder] = $entry } - } + $indirect.Count | Should -BeGreaterThan 0 + } - It 'pulls bytesbuf_io into the release set' { - $script:ByFolder.ContainsKey('bytesbuf_io') | Should -BeTrue - } + It 'raises an indirect exposing package when the defining crate breaks' { + $pair = @( + foreach ($fact in $script:Facts.packages) { + if (-not [bool]$fact.published) { continue } + foreach ($target in @($fact.exposedDeps)) { + if ( + @($fact.deps) -notcontains $target -and + $script:FactsByName.ContainsKey($target) + ) { + [pscustomobject]@{ + Dependent = $fact + Target = $script:FactsByName[$target] + } + } + } + } + ) | Select-Object -First 1 - It 'raises bytesbuf_io to breaking rather than leaving it on the patch floor' { - # THE regression. A 'patch' here is the original bug: a compatible - # release that silently breaks every bytesbuf_io consumer. - $script:ByFolder['bytesbuf_io'].EffectiveChangeType | Should -Be 'breaking' - } + $pair | Should -Not -BeNullOrEmpty + $plan = Invoke-LivePlan -Token "$($pair.Target.folder)@breaking" + $dependent = $plan.releases | + Where-Object folder -eq $pair.Dependent.folder - It 'writes a version for bytesbuf_io that is an incompatible transition' { - $entry = $script:ByFolder['bytesbuf_io'] - # Asserted via the change-type calculation rather than a hardcoded - # version so routine bytesbuf_io releases do not break this test. - $planned = Get-ChangeTypeFromVersions ` - -oldVersion $entry.CurrentVersion ` - -newVersion $entry.EffectiveTargetVersion - Test-IsBreakingChange -oldVersion $entry.CurrentVersion -ChangeType $planned | - Should -BeTrue -Because "$($entry.CurrentVersion) -> $($entry.EffectiveTargetVersion) must be incompatible" - } + $dependent.changeType | Should -Be 'breaking' + @($dependent.cascadeReasons | + Where-Object { $_.target -eq $pair.Target.name -and $_.breaking }).Count | + Should -Be 1 + } - It 'attributes the bytesbuf_io bump to bytesbuf' { - $reason = @($script:ByFolder['bytesbuf_io'].CascadeReasons | Where-Object { $_.Target -eq 'bytesbuf' }) - $reason.Count | Should -Be 1 -Because 'one reason per edge, however many fixpoint passes run' - $reason[0].Breaking | Should -BeTrue - } + It 'separates the templated-uri macro contract from Rust type exposure' { + $impl = $script:FactsByFolder['templated_uri_macros_impl'] + $macros = $script:FactsByFolder['templated_uri_macros'] + $runtime = $script:FactsByFolder['templated_uri'] + + @($impl.exposedDeps) | Should -Not -Contain 'ohno' + $macros.exposureUnknown | Should -BeFalse + @($macros.exposedDeps).Count | Should -Be 0 + @($runtime.macroPublicDeps) | Should -Contain 'templated_uri_macros' + @($runtime.exposedDeps) | Should -Not -Contain 'templated_uri_macros' + @($macros.macroRuntimePartners) | Should -Contain 'templated_uri' } - Context 'planning a compatible bytesbuf release' { - It 'does not raise bytesbuf_io to breaking when bytesbuf stays compatible' { - # The negative control: exposure alone must not force a break. If - # this fails, the cascade is bumping on the edge rather than on the - # incompatibility, and every release becomes a major. - $parsed = Parse-ReleaseTokens -Tokens @('bytesbuf@patch') - $resolved = Resolve-ReleaseSet ` - -ParsedTokens $parsed ` - -WorkspaceBaseline $script:LiveBaseline ` - -GetRequiredChangeType $script:NoSelfChangeClassifier - - $entry = $resolved | Where-Object { $_.Folder -eq 'bytesbuf_io' } - $entry.EffectiveChangeType | Should -Not -Be 'breaking' - } + It 'does not turn wildcard or unpublished macro consumers into runtime partners' { + $ohnoMacros = $script:FactsByFolder['ohno_macros'] + $routeramaMacros = $script:FactsByFolder['routerama_macros'] + + @($ohnoMacros.macroRuntimePartners) | Should -Not -Contain 'automation' + @($routeramaMacros.macroRuntimePartners) | + Should -Not -Contain 'rest_over_grpc_examples' + @($routeramaMacros.macroRuntimePartners) | + Should -Not -Contain 'rest_over_grpc_tests' } -} -Describe 'Re-exported type edges in the live workspace' { - # cargo-check-external-types attributes a re-exported type to its DEFINING - # crate, so a crate can allowlist a workspace crate it does not directly - # depend on. These edges exist today and were invisible to a direct-edge - # scan. Pinning them against real manifests, because the whole failure mode - # is a mismatch between what manifests say and what the planner assumed. - - BeforeAll { - # Every (crate, allowlisted workspace crate) pair where the crate does - # not depend on that workspace crate directly. Self-references are - # excluded: a crate cannot cascade from itself. - $script:IndirectPairs = @() - $byName = @{} - foreach ($p in $script:LiveBaseline) { $byName[$p.Name.Replace('-', '_')] = $p } - - foreach ($pkg in $script:LiveBaseline) { - if ($null -eq $pkg.AllowedExternalTypes) { continue } - $self = $pkg.Name.Replace('-', '_') - $roots = @($pkg.AllowedExternalTypes | - Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | - ForEach-Object { ($_ -split '::', 2)[0] } | Sort-Object -Unique) - foreach ($root in $roots) { - if (-not $byName.ContainsKey($root)) { continue } - if ($root -eq $self) { continue } - if ($pkg.Deps -contains $root) { continue } - $script:IndirectPairs += [pscustomobject]@{ Dependent = $pkg; TargetName = $byName[$root].Name } + It 'keeps compatible templated-uri macro packages at patch while preserving independent type breaks' { + $plan = Invoke-LivePlan -Token 'ohno@breaking' + $impl = $plan.releases | + Where-Object folder -eq 'templated_uri_macros_impl' + $macros = $plan.releases | + Where-Object folder -eq 'templated_uri_macros' + $runtime = $plan.releases | + Where-Object folder -eq 'templated_uri' + + $impl.changeType | Should -Be 'patch' + $macros.changeType | Should -Be 'patch' + $runtime.changeType | Should -Be 'breaking' + } + + Context 'external dependency exposure' { + It 'reports syn exposure for the macro implementation crates that name its types' { + foreach ($folder in @( + 'thread_aware_macros_impl', + 'data_privacy_macros_impl', + 'fundle_macros_impl' + )) { + @($script:FactsByFolder[$folder].externalExposedDeps) | + Should -Contain 'syn' -Because "$folder allowlists syn:: entries" } } - } - It 'still contains at least one indirect allowlist edge to pin' { - # If this ever fails the workspace changed shape; the tests below would - # then be silently vacuous, so fail loudly instead. - @($script:IndirectPairs).Count | Should -BeGreaterThan 0 - } + It 'reports no syn exposure for crates that only use it privately' { + foreach ($folder in @('templated_uri_macros_impl', 'routerama_build')) { + @($script:FactsByFolder[$folder].externalExposedDeps) | + Should -Not -Contain 'syn' -Because "$folder allowlists no syn:: entry" + } + } - It 'treats every indirect allowlist edge as exposure of the defining crate' { - foreach ($pair in $script:IndirectPairs) { - Test-PackageAllowlistNamesTarget -Dependent $pair.Dependent -TargetPackageName $pair.TargetName | - Should -BeTrue -Because "$($pair.Dependent.Folder) allowlists $($pair.TargetName) without depending on it directly" + It 'never reports external exposure for a proc-macro-only crate' { + foreach ($fact in $script:Facts.packages) { + if (-not [bool]$fact.procMacroOnly) { continue } + @($fact.externalExposedDeps).Count | + Should -Be 0 -Because "$($fact.folder) exports behaviour, not foreign types" + } } - } - It 'selects those crates as dependents of the defining crate' { - # The direct-edge scan could not: none of these appear in the target's - # direct dependent list at all. - foreach ($pair in $script:IndirectPairs) { - $target = $script:LiveBaseline | Where-Object { $_.Name -eq $pair.TargetName } - $resolvedStub = [ordered]@{} - foreach ($p in $script:LiveBaseline) { $resolvedStub[$p.Folder] = $true } + It 'emits both lane properties for every workspace package' { + foreach ($fact in $script:Facts.packages) { + $fact.PSObject.Properties['externalDepChanges'] | + Should -Not -BeNullOrEmpty + $fact.PSObject.Properties['externalExposedDeps'] | + Should -Not -BeNullOrEmpty + } + } - $selected = @(Get-PublishedDependentsExposingTarget -TargetPackage $target ` - -WorkspaceBaseline $script:LiveBaseline -Resolved $resolvedStub) + It 'reports only external crates, never workspace members' { + $members = @($script:Facts.packages | ForEach-Object { $_.name.Replace('-', '_') }) + foreach ($fact in $script:Facts.packages) { + foreach ($change in @($fact.externalDepChanges)) { + $members | Should -Not -Contain $change.name + } + } + } - @($selected | Where-Object { $_.Folder -eq $pair.Dependent.Folder }).Count | - Should -Be 1 -Because "$($pair.Dependent.Folder) reaches $($pair.TargetName) and names its types" + It 'orders every package lane deterministically' { + foreach ($fact in $script:Facts.packages) { + $names = @($fact.externalDepChanges | ForEach-Object { $_.name }) + $sorted = [string[]]@($names) + [Array]::Sort($sorted, [StringComparer]::Ordinal) + $names | Should -Be $sorted + } } - } - It 'cascades a breaking bump of each defining crate into those dependents' { - $targets = @($script:IndirectPairs | ForEach-Object { $_.TargetName } | Sort-Object -Unique) - foreach ($targetName in $targets) { - $resolved = Resolve-ReleaseSet ` - -ParsedTokens (Parse-ReleaseTokens -Tokens @("$targetName@breaking")) ` - -WorkspaceBaseline $script:LiveBaseline ` - -GetRequiredChangeType $script:NoSelfChangeClassifier - $byFolder = @{} - foreach ($entry in $resolved) { $byFolder[$entry.Folder] = $entry } - - $expected = @($script:IndirectPairs | Where-Object { $_.TargetName -eq $targetName }) - foreach ($pair in $expected) { - $folder = $pair.Dependent.Folder - $byFolder.ContainsKey($folder) | Should -BeTrue -Because "$folder depends on $targetName transitively" - $byFolder[$folder].EffectiveChangeType | - Should -Be 'breaking' -Because "$folder names $targetName's types in its public API" + It 'blocks a patch plan for any crate whose exposed dependency line moved' { + $floored = @( + foreach ($fact in $script:Facts.packages) { + if (-not [bool]$fact.published) { continue } + $exposed = @($fact.externalExposedDeps) + $breaking = @( + @($fact.externalDepChanges) | + Where-Object { [bool]$_.breaking -and $exposed -contains $_.name } + ) + if ($breaking.Count -gt 0) { $fact } + } + ) + if ($floored.Count -eq 0) { + Set-ItResult -Skipped -Because 'no exposed external break is pending in this tree' + return } + + # Invoke-LivePlan classifies every published package as patch, which + # is exactly the judgement the derived floor has to refuse. + $plan = Invoke-LivePlan -Token $floored[0].folder + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + @($plan.ambiguities | ForEach-Object { $_.kind }) | + Should -Contain 'externalExposureUnderclassified' } } } diff --git a/scripts/tests/Pester/integration/ReleaseSkillApply.Tests.ps1 b/scripts/tests/Pester/integration/ReleaseSkillApply.Tests.ps1 new file mode 100644 index 000000000..b1c934638 --- /dev/null +++ b/scripts/tests/Pester/integration/ReleaseSkillApply.Tests.ps1 @@ -0,0 +1,250 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + . (Join-Path $PSScriptRoot '..\_common\TestHelpers.ps1') + . (Join-Path $PSScriptRoot '..\_common\New-SyntheticWorkspace.ps1') + + $script:ApplyPlan = Join-Path ( + Get-OxiRepoRoot + ) '.github\skills\release-packages\scripts\apply-plan.ps1' + + function Write-TestPlan { + param( + [Parameter(Mandatory = $true)][string]$Path, + [string]$Version = '0.2.0' + ) + + [ordered]@{ + mode = 'targeted' + releases = @( + [ordered]@{ + folder = 'subject' + name = 'subject' + from = '0.1.0' + to = $Version + changeType = 'breaking' + source = 'user' + manualReview = $false + cascadeReasons = @() + } + ) + warnings = @() + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding utf8 + } +} + +Describe 'apply-plan.ps1' { + It 'rejects a blocked plan before writing files' { + $workspace = New-SyntheticWorkspace -Spec @{ + Packages = @( + @{ Name = 'subject'; Version = '0.1.0' } + ) + } -Path (Join-Path $TestDrive 'apply-blocked-plan') + $planPath = Join-Path $workspace.Path 'plan.json' + @{ + status = 'blocked' + mode = 'targeted' + releases = @() + ambiguities = @(@{ kind = 'macroContractUnreviewed' }) + } | ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath $planPath -Encoding utf8 + + { + & $script:ApplyPlan -RepoRoot $workspace.Path -PlanPath $planPath -SkipReadme + } | Should -Throw "*release plan is 'blocked'*" + } + + It 'updates only package and workspace dependency version values' { + $workspace = New-SyntheticWorkspace -Spec @{ + Packages = @( + @{ Name = 'subject'; Version = '0.1.0' } + ) + } -Path (Join-Path $TestDrive 'apply-success') + $planPath = Join-Path $workspace.Path 'plan.json' + Write-TestPlan -Path $planPath + $rootManifest = Join-Path $workspace.Path 'Cargo.toml' + $rootContent = Get-Content -LiteralPath $rootManifest -Raw + $rootContent.Replace( + 'subject = { path = "crates/subject", version = "0.1.0" }', + 'subject = { path = "crates/subject", default-features = false, version = "0.1.0" }' + ) | Set-Content -LiteralPath $rootManifest -NoNewline + + & $script:ApplyPlan -RepoRoot $workspace.Path -PlanPath $planPath -SkipReadme | + Out-Null + + Get-Content (Join-Path $workspace.Path 'crates\subject\Cargo.toml') -Raw | + Should -Match 'version = "0\.2\.0"' + $root = Get-Content $rootManifest -Raw + $root | Should -Match 'subject = \{ path = "crates/subject", default-features = false, version = "0\.2\.0" \}' + Test-Path (Join-Path $workspace.Path 'crates\subject\CHANGELOG.md') | + Should -BeTrue + } + + It 'restores every written file when validation fails' { + $workspace = New-SyntheticWorkspace -Spec @{ + Packages = @( + @{ Name = 'subject'; Version = '0.1.0' } + ) + } -Path (Join-Path $TestDrive 'apply-rollback') + $workspace.WriteFile('crates/subject/src/lib.rs', 'this is not rust') + $planPath = Join-Path $workspace.Path 'plan.json' + Write-TestPlan -Path $planPath + $rootManifest = Join-Path $workspace.Path 'Cargo.toml' + $packageManifest = Join-Path $workspace.Path 'crates\subject\Cargo.toml' + $changelog = Join-Path $workspace.Path 'crates\subject\CHANGELOG.md' + $rootBefore = [System.IO.File]::ReadAllBytes($rootManifest) + $packageBefore = [System.IO.File]::ReadAllBytes($packageManifest) + $changelogBefore = [System.IO.File]::ReadAllBytes($changelog) + + { + & $script:ApplyPlan -RepoRoot $workspace.Path -PlanPath $planPath -SkipReadme + } | Should -Throw '*Command failed: cargo check*' + + [System.IO.File]::ReadAllBytes($rootManifest) | + Should -Be $rootBefore + [System.IO.File]::ReadAllBytes($packageManifest) | + Should -Be $packageBefore + [System.IO.File]::ReadAllBytes($changelog) | + Should -Be $changelogBefore + } + + It 'runs README generation from RepoRoot and removes newly created files on rollback' { + $workspace = New-SyntheticWorkspace -Spec @{ + Packages = @( + @{ Name = 'subject'; Version = '0.1.0' } + ) + } -Path (Join-Path $TestDrive 'apply-readme-rollback') + $workspace.WriteFile('crates/subject/src/lib.rs', 'this is not rust') + $readme = Join-Path $workspace.Path 'crates\subject\README.md' + if (Test-Path -LiteralPath $readme) { + Remove-Item -LiteralPath $readme -Force + } + $planPath = Join-Path $workspace.Path 'plan.json' + Write-TestPlan -Path $planPath + + $bin = Join-Path $TestDrive 'fake-bin' + New-Item -ItemType Directory -Path $bin | Out-Null + if ($IsWindows) { + @' +@echo off +type nul > crates\subject\README.md +'@ | Set-Content -LiteralPath (Join-Path $bin 'just.cmd') -Encoding ascii + } else { + @' +#!/bin/sh +touch crates/subject/README.md +'@ | Set-Content -LiteralPath (Join-Path $bin 'just') -Encoding utf8 + & chmod +x (Join-Path $bin 'just') + } + + $oldPath = $env:PATH + $env:PATH = "$bin$([System.IO.Path]::PathSeparator)$oldPath" + try { + { + & $script:ApplyPlan -RepoRoot $workspace.Path -PlanPath $planPath + } | Should -Throw '*Command failed: cargo check*' + } finally { + $env:PATH = $oldPath + } + + Test-Path -LiteralPath $readme | Should -BeFalse + } + + It 'rejects a stale plan before leaving version edits behind' { + $workspace = New-SyntheticWorkspace -Spec @{ + Packages = @( + @{ Name = 'subject'; Version = '0.1.0' } + ) + } -Path (Join-Path $TestDrive 'apply-stale-plan') + $planPath = Join-Path $workspace.Path 'plan.json' + Write-TestPlan -Path $planPath + $packageManifest = Join-Path $workspace.Path 'crates\subject\Cargo.toml' + $workspace.SetVersion('subject', '0.1.1') + $packageBefore = [System.IO.File]::ReadAllBytes($packageManifest) + + { + & $script:ApplyPlan -RepoRoot $workspace.Path -PlanPath $planPath -SkipReadme + } | Should -Throw "*not planned version '0.1.0'*" + + [System.IO.File]::ReadAllBytes($packageManifest) | + Should -Be $packageBefore + } + + It 'rolls back a package edit when its workspace dependency entry is malformed' { + $workspace = New-SyntheticWorkspace -Spec @{ + Packages = @( + @{ Name = 'subject'; Version = '0.1.0' } + ) + } -Path (Join-Path $TestDrive 'apply-malformed-root') + $planPath = Join-Path $workspace.Path 'plan.json' + Write-TestPlan -Path $planPath + $rootManifest = Join-Path $workspace.Path 'Cargo.toml' + $packageManifest = Join-Path $workspace.Path 'crates\subject\Cargo.toml' + (Get-Content -LiteralPath $rootManifest -Raw).Replace( + ', version = "0.1.0"', + '' + ) | Set-Content -LiteralPath $rootManifest -NoNewline + $rootBefore = [System.IO.File]::ReadAllBytes($rootManifest) + $packageBefore = [System.IO.File]::ReadAllBytes($packageManifest) + + { + & $script:ApplyPlan -RepoRoot $workspace.Path -PlanPath $planPath -SkipReadme + } | Should -Throw '*must be one inline table with a version value*' + + [System.IO.File]::ReadAllBytes($rootManifest) | Should -Be $rootBefore + [System.IO.File]::ReadAllBytes($packageManifest) | Should -Be $packageBefore + } + + It 'rolls back earlier packages when a later plan entry is stale' { + $workspace = New-SyntheticWorkspace -Spec @{ + Packages = @( + @{ Name = 'first'; Version = '0.1.0' } + @{ Name = 'second'; Version = '0.1.0' } + ) + } -Path (Join-Path $TestDrive 'apply-multi-rollback') + $planPath = Join-Path $workspace.Path 'plan.json' + [ordered]@{ + mode = 'targeted' + releases = @( + [ordered]@{ + folder = 'first' + name = 'first' + from = '0.1.0' + to = '0.2.0' + changeType = 'breaking' + source = 'user' + manualReview = $false + cascadeReasons = @() + } + [ordered]@{ + folder = 'second' + name = 'second' + from = '9.9.9' + to = '10.0.0' + changeType = 'breaking' + source = 'user' + manualReview = $false + cascadeReasons = @() + } + ) + warnings = @() + } | ConvertTo-Json -Depth 6 | + Set-Content -LiteralPath $planPath -Encoding utf8 + + $rootManifest = Join-Path $workspace.Path 'Cargo.toml' + $firstManifest = Join-Path $workspace.Path 'crates\first\Cargo.toml' + $firstChangelog = Join-Path $workspace.Path 'crates\first\CHANGELOG.md' + $rootBefore = [System.IO.File]::ReadAllBytes($rootManifest) + $firstBefore = [System.IO.File]::ReadAllBytes($firstManifest) + $changelogBefore = [System.IO.File]::ReadAllBytes($firstChangelog) + + { + & $script:ApplyPlan -RepoRoot $workspace.Path -PlanPath $planPath -SkipReadme + } | Should -Throw "*not planned version '9.9.9'*" + + [System.IO.File]::ReadAllBytes($rootManifest) | Should -Be $rootBefore + [System.IO.File]::ReadAllBytes($firstManifest) | Should -Be $firstBefore + [System.IO.File]::ReadAllBytes($firstChangelog) | Should -Be $changelogBefore + } +} diff --git a/scripts/tests/Pester/integration/ReleaseSkillSemver.Tests.ps1 b/scripts/tests/Pester/integration/ReleaseSkillSemver.Tests.ps1 new file mode 100644 index 000000000..dca295bbc --- /dev/null +++ b/scripts/tests/Pester/integration/ReleaseSkillSemver.Tests.ps1 @@ -0,0 +1,182 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeDiscovery { + $script:HasSemverChecks = + $null -ne (Get-Command cargo-semver-checks -ErrorAction SilentlyContinue) +} + +BeforeAll { + . (Join-Path $PSScriptRoot '..\_common\TestHelpers.ps1') + . (Join-Path $PSScriptRoot '..\_common\New-SyntheticWorkspace.ps1') + . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\releasing.ps1') + + function Invoke-SyntheticSemverCase { + param( + [Parameter(Mandatory = $true)][string]$Name, + [string]$BaselineSource = @' +pub fn existing() -> u32 { + 1 +} +'@, + [Parameter(Mandatory = $true)][string]$CurrentSource + ) + + $root = Join-Path $TestDrive $Name + $workspace = New-SyntheticWorkspace -Spec @{ + Packages = @( + @{ Name = 'subject'; Version = '1.0.0' } + ) + } -Path $root + + $workspace.WriteFile('crates/subject/src/lib.rs', $BaselineSource) + $workspace.AddCommit('feat(subject): establish public API') + $baseline = $workspace.GitSha() + $workspace.WriteFile('crates/subject/src/lib.rs', $CurrentSource) + + $oldNativeErrorPreference = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + try { + $output = & cargo semver-checks ` + --manifest-path (Join-Path $workspace.Path 'Cargo.toml') ` + --package subject ` + --baseline-rev $baseline ` + --release-type patch ` + --all-features ` + --color never 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } finally { + $PSNativeCommandUseErrorActionPreference = $oldNativeErrorPreference + } + return ConvertFrom-SemverChecksOutput ` + -Output $output ` + -ExitCode $exitCode ` + -PackageName subject + } +} + +Describe 'release skill classifications against synthetic Cargo changes' { + It 'classifies an internal implementation edit as patch' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase -Name patch -CurrentSource @' +pub fn existing() -> u32 { + helper() +} + +fn helper() -> u32 { + 2 +} +'@ | Should -Be 'patch' + } + + It 'leaves backward-compatible additions for source-diff classification' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase -Name additive -CurrentSource @' +pub fn existing() -> u32 { + 1 +} + +pub fn added() -> u32 { + 2 +} +'@ | Should -Be 'patch' + } + + It 'classifies removal of a public function as breaking' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase -Name breaking -CurrentSource @' +pub fn replacement() -> u32 { + 2 +} +'@ | Should -Be 'breaking' + } + + It 'leaves a public parameter type change for source-diff classification' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase ` + -Name parameter-type ` + -BaselineSource @' +pub fn convert(value: u32) -> u32 { + value +} +'@ ` + -CurrentSource @' +pub fn convert(value: u64) -> u32 { + value as u32 +} +'@ | Should -Be 'patch' + } + + It 'leaves a public return type change for source-diff classification' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase ` + -Name return-type ` + -BaselineSource @' +pub fn value() -> u32 { + 1 +} +'@ ` + -CurrentSource @' +pub fn value() -> u64 { + 1 +} +'@ | Should -Be 'patch' + } + + It 'classifies removal of a public struct field as breaking' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase ` + -Name struct-field ` + -BaselineSource @' +pub struct Config { + pub value: u32, +} +'@ ` + -CurrentSource @' +pub struct Config {} +'@ | Should -Be 'breaking' + } + + It 'classifies a required trait method addition as breaking' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase ` + -Name required-trait-method ` + -BaselineSource @' +pub trait Service { + fn existing(&self); +} +'@ ` + -CurrentSource @' +pub trait Service { + fn existing(&self); + fn added(&self); +} +'@ | Should -Be 'breaking' + } + + It 'classifies an exhaustive enum variant addition as breaking' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase ` + -Name enum-variant ` + -BaselineSource @' +pub enum State { + Ready, +} +'@ ` + -CurrentSource @' +pub enum State { + Ready, + Waiting, +} +'@ | Should -Be 'breaking' + } + + It 'allows a provided trait method addition' -Skip:(-not $script:HasSemverChecks) { + Invoke-SyntheticSemverCase ` + -Name provided-trait-method ` + -BaselineSource @' +pub trait Service { + fn existing(&self); +} +'@ ` + -CurrentSource @' +pub trait Service { + fn existing(&self); + + fn added(&self) {} +} +'@ | Should -Be 'patch' + } +} diff --git a/scripts/tests/Pester/integration/Releasing-Integration.Tests.ps1 b/scripts/tests/Pester/integration/Releasing-Integration.Tests.ps1 deleted file mode 100644 index 11a027efd..000000000 --- a/scripts/tests/Pester/integration/Releasing-Integration.Tests.ps1 +++ /dev/null @@ -1,1239 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# -# Phase 5 — integration tests for the analyses that orchestrate multiple -# helpers. Each test uses a tiny synthetic Cargo workspace and exercises a -# realistic interplay between version changes, source edits, and the -# release-set / unreleased-modified-deps analyses. The N1..N9 scenarios -# previously documented in scripts/tests/RELEASE-DEPS-TEST-CASES.md (since -# deleted) are encoded here. - -BeforeAll { - . (Join-Path $PSScriptRoot '..\_common\TestHelpers.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\releasing.ps1') - . (Join-Path $PSScriptRoot '..\_common\New-SyntheticWorkspace.ps1') -} - -# -------------------------------------------------------------------------- -# Get-UnreleasedModifiedDependencies — BFS / aggregation coverage. -# -------------------------------------------------------------------------- - -Describe 'Get-UnreleasedModifiedDependencies: BFS / topology' { - - It 'N1 — modified dependency + version-changed dependent in same PR is flagged' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'n1') - # Earlier baseline = initial commit. In this PR: modify dependency + change dependent. - $ws.ModifySource('dependency') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('PR commit') - # dependent's release artefact must have source modifications past its - # baseline for the LIVE filter to use it as a BFS root. - $ws.ModifySource('dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $up = $findings | Where-Object { $_.Folder -eq 'dependency' } - $up | Should -Not -BeNullOrEmpty - $up.DependencyChains[0] | Should -Be @('dependent', 'dependency') - # CurrentVersion threads through from cargo metadata so the menu can - # render concrete version transitions (e.g. "0.2.0 -> 0.3.0"). - $up.CurrentVersion | Should -Be '0.2.0' - } - - It 'N2 — earlier-PR dependency edit + current-PR dependent change is flagged' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'n2') - # Simulate previous PR landing an dependency edit without a version change: - $ws.ModifySource('dependency') - $ws.AddCommit('previous PR: dependency edit') - # Current PR changes dependent only: - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('current PR: dependent version change') - $ws.ModifySource('dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Contain 'dependency' - } - - It 'N3 — dependency already version-changed cleanly; no further edits → no finding' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'n3') - # Previous PR: change dependency and release. - $ws.SetVersion('dependency', '0.2.1') - $ws.AddCommit('release dependency 0.2.1') - # Current PR: change dependent only. - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('release dependent 0.1.1') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Count | Should -Be 0 - } - - It 'N4 — change-then-edit dependency is flagged via per-package baseline' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'n4') - # Earlier: change dependency + release. - $ws.SetVersion('dependency', '0.2.1') - $ws.AddCommit('release dependency 0.2.1') - # Later: edit dependency source (no version change). - $ws.ModifySource('dependency') - $ws.AddCommit('post-release dependency edit') - # Current PR: change dependent only. - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('release dependent') - $ws.ModifySource('dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Contain 'dependency' - } - - It 'N5 — BFS reaches a modified leaf through an unchanged middle' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive 'n5') - # Modify the deepest leaf 'c' in an earlier PR. - $ws.ModifySource('c') - $ws.AddCommit('previous PR: c edit') - # Current PR: change 'a' only. Middle 'b' is unchanged. - $ws.SetVersion('a', '0.1.1') - $ws.AddCommit('current PR: change a') - $ws.ModifySource('a') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Contain 'c' - $cFinding = $findings | Where-Object { $_.Folder -eq 'c' } - $cFinding.DependencyChains[0] | Should -Be @('a', 'b', 'c') - } - - It 'N6 — CHANGELOG-only edit in dependency still flagged (humans decide materiality)' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'n6') - $changelog = Join-Path $ws.Path 'crates\dependency\CHANGELOG.md' - Add-Content -Path $changelog -Value "`n* maintenance note`n" - $ws.AddCommit('previous PR: dependency changelog tweak') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('current PR: change dependent') - $ws.ModifySource('dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Contain 'dependency' - } - - It 'N7 — publish=false → true flip resets the baseline (pre-flip edits ignored)' { - Reset-ReleaseScriptCaches - # Build a workspace where 'dependency' starts as publish=false with pre-flip - # edits, then is flipped to publish=true on a later commit. Current PR changes - # dependent only; pre-flip edits must not be reported. - $spec = @{ - Packages = @( - @{ Name = 'dependent'; Version = '0.1.0'; Deps = @(@{ Name = 'dependency' }) } - @{ Name = 'dependency'; Version = '0.2.0'; Published = $false } - ) - } - $ws = New-SyntheticWorkspace -Spec $spec -Path (Join-Path $TestDrive 'n7') - # Pre-flip source edit (while publish=false). - $ws.ModifySource('dependency') - $ws.AddCommit('pre-flip edit') - # Flip publish to true. - $cargo = Join-Path $ws.Path 'crates\dependency\Cargo.toml' - $content = Get-Content $cargo -Raw - $content = $content -replace 'publish\s*=\s*false', 'publish = true' - Set-Content $cargo -Value $content -NoNewline - $ws.AddCommit('publish=true flip') - # Current PR: change dependent only. - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('release dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - # No findings: per-package baseline for dependency is the publish-flip commit, - # newer than the pre-flip edit, so no unreleased changes. - $findings.Count | Should -Be 0 - } - - It 'N8 — working-tree edits on dependency are flagged' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'n8') - # Current PR: change dependent (committed). - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('change dependent') - # Uncommitted: tweak dependency source AND dependent source so dependent - # qualifies as a BFS root under the LIVE filter. - $ws.ModifySource('dependency') - $ws.ModifySource('dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Contain 'dependency' - } - - It 'N9 — untracked new file in dependency is flagged' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'n9') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('change dependent') - Set-Content -Path (Join-Path $ws.Path 'crates\dependency\src\extra.rs') -Value '// new' - $ws.ModifySource('dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Contain 'dependency' - } - - It 'T6b — dev-only dep on a modified package is NOT flagged' { - Reset-ReleaseScriptCaches - # Mixed6's 'target' has a dev-dep on dependency_a (normal dep on dependency_b). - $ws = New-SyntheticWorkspace -Preset Mixed6 -Path (Join-Path $TestDrive 't6b') - $ws.ModifySource('dependency_a') - $ws.AddCommit('dependency_a edit') - $ws.SetVersion('target', '0.1.1') - $ws.AddCommit('change target') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Not -Contain 'dependency_a' - } - - It 'T15 — publish=false dep is NOT flagged even when modified' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Mixed6 -Path (Join-Path $TestDrive 't15') - # 'utility' is publish=false. Modify it and version-change dependent_y which depends on it. - $ws.ModifySource('utility') - $ws.AddCommit('utility edit') - $ws.SetVersion('dependent_y', '0.5.1') - $ws.AddCommit('change dependent_y') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Not -Contain 'utility' - } - - It 'T16-style aggregation — one shared dependency across multiple version-changed dependents gets multiple chains' { - Reset-ReleaseScriptCaches - # Diamond4: top -> {left, right}; left -> bottom; right -> bottom. - # Modify bottom in an earlier PR; change both left and right. - $ws = New-SyntheticWorkspace -Preset Diamond4 -Path (Join-Path $TestDrive 't16-style') - $ws.ModifySource('bottom') - $ws.AddCommit('previous PR: bottom edit') - $ws.SetVersion('left', '0.2.1') - $ws.SetVersion('right', '0.3.1') - $ws.AddCommit('current PR: change left + right') - # Both release-set members must have source mods past their baselines - # for the LIVE filter to use them as BFS roots. - $ws.ModifySource('left') - $ws.ModifySource('right') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $bottom = $findings | Where-Object { $_.Folder -eq 'bottom' } - $bottom | Should -Not -BeNullOrEmpty - @($bottom.DependencyChains).Count | Should -Be 2 - } - - It 'Detached — modified package in component B does not surface from a release in component A' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Detached -Path (Join-Path $TestDrive 'detached') - # Two disconnected components: alpha→beta and gamma→delta. - # Modify 'gamma' (component B) and change 'alpha' (component A). - $ws.ModifySource('gamma') - $ws.AddCommit('mod gamma') - $ws.SetVersion('alpha', '0.1.1') - $ws.AddCommit('change alpha') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Not -Contain 'gamma' - $findings.Folder | Should -Not -Contain 'delta' - } - - It 'N10 — BFS traverses past release-set intermediates and chains are suffix-subsumed' { - Reset-ReleaseScriptCaches - # Linear3: a → b → c. Modify 'c' (unreleased). Release set = {a, b}. - # Expected: the chain 'a -> b -> c' subsumes 'b -> c', leaving one chain. - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive 'n10') - $ws.ModifySource('c') - $ws.AddCommit('previous PR: c edit') - $ws.SetVersion('a', '0.1.1') - $ws.SetVersion('b', '0.2.1') - $ws.AddCommit('current PR: change a + b') - # Release-set members must have source mods past their baselines for the - # LIVE filter to use them as BFS roots. - $ws.ModifySource('a') - $ws.ModifySource('b') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $findings.Folder | Should -Contain 'c' - $cFinding = $findings | Where-Object { $_.Folder -eq 'c' } - @($cFinding.DependencyChains).Count | Should -Be 1 - @($cFinding.DependencyChains)[0] -join ',' | Should -Be 'a,b,c' - } - - It 'tags non-release-set findings with InReleaseSet = $false' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'irs-classic') - $ws.ModifySource('dependency') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('mod dependency + change dependent') - $ws.ModifySource('dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $u = $findings | Where-Object { $_.Folder -eq 'dependency' } - $u | Should -Not -BeNullOrEmpty - $u.InReleaseSet | Should -BeFalse - } -} - -# -------------------------------------------------------------------------- -# Get-UnreleasedModifiedDependencies — Invariant B (release-set members -# whose cascade-applied change type is below "breaking" must surface as elevation -# candidates) and the -ModifiedSnapshot mechanism (Invariant A: cascade -# writes must not pollute the working-tree query). -# -------------------------------------------------------------------------- - -Describe 'Get-UnreleasedModifiedDependencies: release-set elevation (Invariant B)' { - - # Helper: build a Linear2 workspace where 'dependency' is BOTH a release-set - # member (its version differs from BaseRef) AND has unreleased - # modifications past its per-package baseline. We arrange this by: - # HEAD~2 → initial (dependency at 0.2.0) - # HEAD~1 → dependency version-changed to $dependencyPending (this becomes dependency's - # per-package baseline; release-set membership against - # BaseRef=HEAD~2 depends on the version differing) - # HEAD → source edit on dependency + change dependent so the loop has - # something to traverse from. Now dependency is in the release - # set AND has modifications post-baseline. - function script:NewElevationWorkspace { - param( - [string]$Path, - [string]$DependencyPending # the in-PR pending version for dependency - ) - $ws = New-SyntheticWorkspace -Preset Linear2 -Path $Path - $ws.SetVersion('dependency', $DependencyPending) - $ws.AddCommit('change dependency (pending release)') - $ws.ModifySource('dependency', '// post-release edit, may warrant elevation') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('mod dependency + change dependent') - return $ws - } - - It 'surfaces a release-set member whose cascade-applied change type is patch (0.x — Invariant B)' { - Reset-ReleaseScriptCaches - # dependency goes 0.2.0 → 0.2.1 (patch); per Test-IsBreakingChange this - # is non-breaking, so the user should be prompted to elevate. - $ws = NewElevationWorkspace -Path (Join-Path $TestDrive 'irs-patch') -DependencyPending '0.2.1' - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2')) - $u = $findings | Where-Object { $_.Folder -eq 'dependency' } - $u | Should -Not -BeNullOrEmpty - $u.InReleaseSet | Should -BeTrue - $u.CurrentVersion | Should -Be '0.2.1' - } - - It 'does NOT surface a release-set member whose cascade-applied change type is breaking (0.x breaking)' { - Reset-ReleaseScriptCaches - # dependency goes 0.2.0 → 0.3.0 (major-on-0.x, i.e. breaking per - # Test-IsBreakingChange) — no further elevation is possible, so the - # user should not be prompted. - $ws = NewElevationWorkspace -Path (Join-Path $TestDrive 'irs-major0x') -DependencyPending '0.3.0' - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2')) - $findings | Where-Object { $_.Folder -eq 'dependency' } | Should -BeNullOrEmpty - } - - It 'surfaces a release-set member whose cascade-applied change type is non-breaking on 1.x' { - Reset-ReleaseScriptCaches - # Build a 1.x workspace so non-breaking (minor) is distinct from - # breaking (major) in cargo-semver terms. - $spec = @{ - Packages = @( - @{ Name = 'dependent'; Version = '1.0.0'; Deps = @(@{ Name = 'dependency' }) } - @{ Name = 'dependency'; Version = '1.2.3' } - ) - } - $ws = New-SyntheticWorkspace -Spec $spec -Path (Join-Path $TestDrive 'irs-1x-minor') - $ws.SetVersion('dependency', '1.3.0') - $ws.AddCommit('pending minor release of dependency') - $ws.ModifySource('dependency', '// post-release edit') - $ws.SetVersion('dependent', '1.0.1') - $ws.AddCommit('mod dependency + change dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2')) - $u = $findings | Where-Object { $_.Folder -eq 'dependency' } - $u | Should -Not -BeNullOrEmpty - $u.InReleaseSet | Should -BeTrue - $u.CurrentVersion | Should -Be '1.3.0' - } - - It 'does NOT surface a release-set member whose cascade-applied change type is breaking on 1.x' { - Reset-ReleaseScriptCaches - $spec = @{ - Packages = @( - @{ Name = 'dependent'; Version = '1.0.0'; Deps = @(@{ Name = 'dependency' }) } - @{ Name = 'dependency'; Version = '1.2.3' } - ) - } - $ws = New-SyntheticWorkspace -Spec $spec -Path (Join-Path $TestDrive 'irs-1x-major') - $ws.SetVersion('dependency', '2.0.0') - $ws.AddCommit('pending major release of dependency') - $ws.ModifySource('dependency', '// post-release edit') - $ws.SetVersion('dependent', '1.0.1') - $ws.AddCommit('mod dependency + change dependent') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2')) - $findings | Where-Object { $_.Folder -eq 'dependency' } | Should -BeNullOrEmpty - } - - It 'still surfaces a release-set member whose pending change type is patch, even when only the working tree carries the modifications' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'irs-worktree') - $ws.SetVersion('dependency', '0.2.1') - $ws.AddCommit('pending patch release of dependency') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('change dependent') - # Uncommitted source edit on dependency — past its per-package baseline. - $ws.ModifySource('dependency', '// uncommitted further edit') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2')) - $u = $findings | Where-Object { $_.Folder -eq 'dependency' } - $u | Should -Not -BeNullOrEmpty - $u.InReleaseSet | Should -BeTrue - } -} - -# -------------------------------------------------------------------------- -# Get-UnreleasedModifiedDependencies — LIVE-flow filter contract: -# A release-set member is only treated as a BFS root when it ALSO has its -# own source modifications past its per-package baseline. Pure-cascade -# members (version bump only, no source changes) cannot have started -# consuming new features in their dependencies, so BFS from them would -# only produce false positives. -# -------------------------------------------------------------------------- - -Describe 'Get-UnreleasedModifiedDependencies: LIVE-flow BFS-root filter' { - - # Helper: build a Linear2 workspace where 'dependency' has source mods, - # 'dependent' depends on 'dependency', and provide a ResolvedReleaseSet - # parameterised by 'dependent's Source ('cascade' or 'user') and - # whether 'dependent' has its own modifications. - function script:NewLiveFilterFixture { - param( - [string]$Path, - [switch]$ModifyDependent - ) - $ws = New-SyntheticWorkspace -Preset Linear2 -Path $Path - $ws.ModifySource('dependency') - if ($ModifyDependent) { $ws.ModifySource('dependent') } - $ws.AddCommit('mods') - return $ws - } - - function script:NewSyntheticReleaseSet { - param([string]$Folder, [string]$Name, [string]$Source) - @{ $Folder = [pscustomobject]@{ - Folder = $Folder - Name = $Name - CurrentVersion = '0.1.0' - EffectiveChangeType = 'patch' - EffectiveTargetVersion = '0.1.1' - Source = $Source - AutoUpgraded = $false - CascadeReasons = New-Object 'System.Collections.Generic.List[object]' - } } - } - - It 'does NOT BFS from a cascade-source release-set member with no own modifications (so its modified deps are not surfaced)' { - Reset-ReleaseScriptCaches - # 'dependent' is in the release set but only because of a - # mechanical cascade bump; it has no source changes of its own. - # 'dependency' IS modified. Under the LIVE filter, 'dependent' is - # NOT a BFS root, so 'dependency' is never reached and no findings - # surface — the user is not pestered about an unreachable dep. - $ws = NewLiveFilterFixture -Path (Join-Path $TestDrive 'live-cascade-no-mods') - $set = NewSyntheticReleaseSet -Folder 'dependent' -Name 'dependent' -Source 'cascade' - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet $set) - $findings | Should -BeNullOrEmpty - } - - It 'DOES BFS from a cascade-source release-set member that has its own modifications (its modified deps surface for review)' { - Reset-ReleaseScriptCaches - # Same shape but 'dependent' has its OWN modifications. Under the - # LIVE filter it IS a BFS root, so 'dependency' is reachable and - # surfaces as a dep finding. 'dependent' itself also surfaces via - # the Phase B sweep (Invariant B: cascade-source, below-breaking, - # with own mods → elevation candidate). - $ws = NewLiveFilterFixture -Path (Join-Path $TestDrive 'live-cascade-with-mods') -ModifyDependent - $set = NewSyntheticReleaseSet -Folder 'dependent' -Name 'dependent' -Source 'cascade' - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet $set) - $u = $findings | Where-Object { $_.Folder -eq 'dependency' } - $u | Should -Not -BeNullOrEmpty - $u.InReleaseSet | Should -BeFalse - } - - It 'does NOT BFS from a user-source release-set member without its own modifications (the precondition is mods, regardless of Source)' { - Reset-ReleaseScriptCaches - # The LIVE filter is source-agnostic: a user-source release-set - # member with no source modifications past its baseline is also - # not a BFS root. (In production this case is rare because the - # user typically only releases packages they have edited, but the - # filter is intentionally symmetric — a user-source release-set - # entry without source mods cannot have started depending on - # unreleased dependency features either.) - $ws = NewLiveFilterFixture -Path (Join-Path $TestDrive 'live-user-no-mods') - $set = NewSyntheticReleaseSet -Folder 'dependent' -Name 'dependent' -Source 'user' - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet $set) - $findings | Should -BeNullOrEmpty - } - - It 'DOES BFS from a user-source release-set member that has its own modifications' { - Reset-ReleaseScriptCaches - $ws = NewLiveFilterFixture -Path (Join-Path $TestDrive 'live-user-with-mods') -ModifyDependent - $set = NewSyntheticReleaseSet -Folder 'dependent' -Name 'dependent' -Source 'user' - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet $set) - $u = $findings | Where-Object { $_.Folder -eq 'dependency' } - $u | Should -Not -BeNullOrEmpty - $u.InReleaseSet | Should -BeFalse - # User-source members are excluded from Phase B Invariant B sweep, - # so 'dependent' itself must NOT appear in findings. - $findings | Where-Object { $_.Folder -eq 'dependent' } | Should -BeNullOrEmpty - } -} - -Describe 'Get-UnreleasedModifiedDependencies: -ModifiedSnapshot honored (Invariant A)' { - - It 'uses the caller-provided snapshot instead of querying the working tree' { - Reset-ReleaseScriptCaches - # Build a workspace where the working tree has NO unreleased - # modifications on dependency — only a pending dependent version change. - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'ms-fake-snap') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('pending dependent change') - - # Without a snapshot: the live query finds nothing on dependency. - $live = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $live.Folder | Should -Not -Contain 'dependency' - - # With a synthetic snapshot claiming both dependency IS modified AND the - # release-set member 'dependent' has source modifications past its - # baseline (required by the LIVE filter for dependent to be a BFS - # root), the BFS surfaces dependency as a classic (non-release-set) - # finding. - $snap = @{ 'dependency' = 3; 'dependent' = 1 } - $with = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1') -ModifiedSnapshot $snap) - $u = $with | Where-Object { $_.Folder -eq 'dependency' } - $u | Should -Not -BeNullOrEmpty - $u.InReleaseSet | Should -BeFalse - $u.ChangedFileCount | Should -Be 3 - } - - It 'returns no findings when the snapshot is empty even if the live query would find some' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'ms-empty-snap') - # Live: dependency has an unreleased modification past its baseline. - $ws.ModifySource('dependency') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('mod dependency + change dependent') - # dependent needs source mods past its baseline to be a BFS root. - $ws.ModifySource('dependent') - - # Sanity check that the live query DOES find it. - $live = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $live.Folder | Should -Contain 'dependency' - - # With an empty snapshot, the BFS surfaces nothing. - $with = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1') -ModifiedSnapshot @{}) - $with.Count | Should -Be 0 - } -} - -# -------------------------------------------------------------------------- -# Get-UnreleasedModifiedDependencies — -IncludeAllModifiedAsRoots switch. -# Models the "imaginary `*` package depends on every changed package" UX -# without a sentinel: every modified-published package is either reached as -# a dep (real chain recorded) or added as a stub finding with empty chains -# (rendered as "No dependents in release set" by the menu). -# -------------------------------------------------------------------------- - -Describe 'Get-UnreleasedModifiedDependencies: -IncludeAllModifiedAsRoots' { - - It 'surfaces both changed packages with a real chain when one depends on the other' { - Reset-ReleaseScriptCaches - # dependent → dependency. Both modified, no release set yet (iteration 1 - # of all-changed mode). Expect: 2 findings; dependency has chain - # [dependent, dependency]; dependent has empty chains (no other root - # reaches it). - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'iamar-inter-dep') - $snap = @{ dependency = 1; dependent = 2 } - $findings = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet @{} ` - -ModifiedSnapshot $snap -IncludeAllModifiedAsRoots) - - $findings.Count | Should -Be 2 - $up = $findings | Where-Object { $_.Folder -eq 'dependency' } - $up | Should -Not -BeNullOrEmpty - $up.InReleaseSet | Should -BeFalse - @($up.DependencyChains).Count | Should -Be 1 - $up.DependencyChains[0] | Should -Be @('dependent', 'dependency') - - $dn = $findings | Where-Object { $_.Folder -eq 'dependent' } - $dn | Should -Not -BeNullOrEmpty - $dn.InReleaseSet | Should -BeFalse - @($dn.DependencyChains).Count | Should -Be 0 - } - - It 'surfaces both changed packages as stubs when they have no inter-dependency' { - Reset-ReleaseScriptCaches - # Detached preset: alpha → beta, gamma → delta. Modify only beta and - # delta (the leaves of each disjoint chain). Neither depends on the - # other, no release set. Expect: 2 stub findings, both with empty - # chains. - $ws = New-SyntheticWorkspace -Preset Detached -Path (Join-Path $TestDrive 'iamar-no-inter') - $snap = @{ beta = 1; delta = 1 } - $findings = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet @{} ` - -ModifiedSnapshot $snap -IncludeAllModifiedAsRoots) - - $findings.Count | Should -Be 2 - $findings.Folder | Sort-Object | Should -Be @('beta', 'delta') - foreach ($f in $findings) { - $f.InReleaseSet | Should -BeFalse - @($f.DependencyChains).Count | Should -Be 0 - } - } - - It 'surfaces a single changed package as a stub when its dependents are unchanged' { - Reset-ReleaseScriptCaches - # Linear2: dependent → dependency. Only dependency is changed; dependent - # is unchanged (and not in release set). With -IncludeAllModifiedAsRoots - # and empty release set, only dependency is a BFS root. It has no deps - # of its own, so no chain is recorded — Phase B adds it as a stub. - # dependent is NOT a finding because it isn't modified. - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'iamar-lone-changed') - $snap = @{ dependency = 1 } - $findings = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet @{} ` - -ModifiedSnapshot $snap -IncludeAllModifiedAsRoots) - - $findings.Count | Should -Be 1 - $findings[0].Folder | Should -Be 'dependency' - $findings[0].InReleaseSet | Should -BeFalse - @($findings[0].DependencyChains).Count | Should -Be 0 - } - - It 'does NOT add stubs for modified-published packages that are user-source release-set members' { - Reset-ReleaseScriptCaches - # Linear2: dependent → dependency. Both modified. Release set contains - # dependent as user-source (the user has already decided to release - # it). Expect: dependency surfaces as a finding via BFS from dependent; - # dependent does NOT surface as a stub (user-source members are - # excluded by the surfacing predicate — the user has already decided). - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'iamar-usersrc') - $rs = @{ - dependent = [pscustomobject]@{ - Folder = 'dependent' - Source = 'user' - EffectiveChangeType = 'patch' - EffectiveTargetVersion = '0.1.1' - CurrentVersion = '0.1.0' - AutoUpgraded = $false - CascadeReasons = @() - } - } - $snap = @{ dependency = 1; dependent = 2 } - $findings = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet $rs ` - -ModifiedSnapshot $snap -IncludeAllModifiedAsRoots) - - $findings.Folder | Should -Contain 'dependency' - $findings.Folder | Should -Not -Contain 'dependent' - $up = $findings | Where-Object { $_.Folder -eq 'dependency' } - $up.DependencyChains[0] | Should -Be @('dependent', 'dependency') - } - - It 'returns no findings when both the release set and modified map are empty (regression for early-return)' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'iamar-empty') - $findings = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet @{} ` - -ModifiedSnapshot @{} -IncludeAllModifiedAsRoots) - $findings.Count | Should -Be 0 - } - - It 'behaves identically with or without the switch when no extra changed packages exist beyond the release set' { - Reset-ReleaseScriptCaches - # Linear2: only dependency changed; dependent is a user-source release-set - # member (the user has already decided to release it). Both members carry - # modifications past their baselines. Without the switch, only dependent - # is a BFS root and surfaces dependency via 'dependent -> dependency'. With - # the switch, dependency is also a BFS root (no deps, no extra chains) and - # Phase B skips it (already a finding). dependent is excluded from the - # Phase B sweep in both modes because it is user-source. Both calls - # should produce the same single finding with the same chain. - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'iamar-regression') - $rs = @{ - dependent = [pscustomobject]@{ - Folder = 'dependent' - Source = 'user' - EffectiveChangeType = 'patch' - EffectiveTargetVersion = '0.1.1' - CurrentVersion = '0.1.0' - AutoUpgraded = $false - CascadeReasons = @() - } - } - $snap = @{ dependency = 1; dependent = 1 } - $without = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet $rs -ModifiedSnapshot $snap) - $with = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet $rs -ModifiedSnapshot $snap ` - -IncludeAllModifiedAsRoots) - - $without.Count | Should -Be 1 - $with.Count | Should -Be 1 - $without[0].Folder | Should -Be 'dependency' - $with[0].Folder | Should -Be 'dependency' - $without[0].DependencyChains[0] | Should -Be @('dependent', 'dependency') - $with[0].DependencyChains[0] | Should -Be @('dependent', 'dependency') - } -} - -# -------------------------------------------------------------------------- -# WorkspaceDependencyChains — populated on every finding from -# Get-UnreleasedModifiedDependencies. Records EVERY in-workspace dependency -# chain ending at the finding's folder, irrespective of release-set -# membership. Used by the per-package menu to give the reviewer a -# "big picture" view of what releasing this package could ripple through — -# cascading may pull more dependents into the release set after the prompt, -# so the release-set-rooted DependencyChains would otherwise be misleadingly -# narrow. -# -------------------------------------------------------------------------- - -Describe 'WorkspaceDependencyChains on findings' { - - It 'records every workspace dependency chain ending at the target (linear)' { - Reset-ReleaseScriptCaches - # Linear3: a → b → c. Modify c in an earlier PR; change a only. - # WorkspaceDependencyChains for c should be the single chain [a,b,c] - # (the only path in the workspace ending at c). - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive 'wdc-linear') - $ws.ModifySource('c') - $ws.AddCommit('previous PR: c edit') - $ws.SetVersion('a', '0.1.1') - $ws.AddCommit('current PR: change a') - $ws.ModifySource('a') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $cFinding = $findings | Where-Object { $_.Folder -eq 'c' } - $cFinding | Should -Not -BeNullOrEmpty - @($cFinding.WorkspaceDependencyChains).Count | Should -Be 1 - $cFinding.WorkspaceDependencyChains[0] | Should -Be @('a', 'b', 'c') - } - - It 'records both paths in a diamond topology' { - Reset-ReleaseScriptCaches - # Diamond4: top → {left, right}; left → bottom; right → bottom. - # Modify bottom (earlier PR); change top (current PR) so bottom - # surfaces as a finding. WorkspaceDependencyChains for bottom should - # contain BOTH paths through the diamond: - # top → left → bottom - # top → right → bottom - # — irrespective of which packages are in the release set. - $ws = New-SyntheticWorkspace -Preset Diamond4 -Path (Join-Path $TestDrive 'wdc-diamond') - $ws.ModifySource('bottom') - $ws.AddCommit('previous PR: bottom edit') - $ws.SetVersion('top', '0.1.1') - $ws.AddCommit('current PR: change top') - $ws.ModifySource('top') - - $findings = @(Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~1')) - $bottom = $findings | Where-Object { $_.Folder -eq 'bottom' } - $bottom | Should -Not -BeNullOrEmpty - @($bottom.WorkspaceDependencyChains).Count | Should -Be 2 - $rendered = @($bottom.WorkspaceDependencyChains | ForEach-Object { $_ -join ',' } | Sort-Object) - $rendered | Should -Be @('top,left,bottom', 'top,right,bottom') - } - - It 'is empty for a leaf package with no in-workspace dependents (regression for "no in-workspace dependents" menu hint)' { - Reset-ReleaseScriptCaches - # Linear2: dependent → dependency. With -IncludeAllModifiedAsRoots the - # changed dependent surfaces as a stub finding. Nothing else in the - # workspace depends on dependent, so WorkspaceDependencyChains is @(). - # The menu will render "no in-workspace dependents" for it. - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'wdc-leaf') - $snap = @{ dependent = 1 } - $findings = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet @{} ` - -ModifiedSnapshot $snap -IncludeAllModifiedAsRoots) - - $findings.Count | Should -Be 1 - $findings[0].Folder | Should -Be 'dependent' - @($findings[0].WorkspaceDependencyChains).Count | Should -Be 0 - } - - It 'is independent of release-set membership: same chain whether or not the dependent is in the release set' { - Reset-ReleaseScriptCaches - # Linear2: dependent → dependency. Modify dependency in earlier PR; do - # NOT change dependent (i.e. dependent is NOT in the release set - # via the BaseRef helper). With -IncludeAllModifiedAsRoots dependency - # surfaces as a stub (DependencyChains is empty because no release - # set member depends on it), but WorkspaceDependencyChains must still - # list [dependent, dependency] — the big-picture view ignores - # release-set membership. - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'wdc-rs-independent') - $ws.ModifySource('dependency') - $ws.AddCommit('earlier PR: dependency edit') - - $snap = @{ dependency = 1 } - $findings = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $ws.Path -ResolvedReleaseSet @{} ` - -ModifiedSnapshot $snap -IncludeAllModifiedAsRoots) - - $findings.Count | Should -Be 1 - $up = $findings[0] - $up.Folder | Should -Be 'dependency' - # Release-set-rooted DependencyChains is empty (no release-set member - # depends on dependency — release set is empty here). - @($up.DependencyChains).Count | Should -Be 0 - # Workspace-wide chains list the full graph path regardless. - @($up.WorkspaceDependencyChains).Count | Should -Be 1 - $up.WorkspaceDependencyChains[0] | Should -Be @('dependent', 'dependency') - } -} - -# -------------------------------------------------------------------------- -# Update-PackageVersion — exercise the [package]-scoped replacement. -# -------------------------------------------------------------------------- - -Describe 'Update-PackageVersion' { - BeforeAll { - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') - } - - It 'updates the package version in its own Cargo.toml' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'uvc-basic') - $packageCargo = Join-Path $ws.Path 'crates\dependent\Cargo.toml' - $rootCargo = Join-Path $ws.Path 'Cargo.toml' - $new = Update-PackageVersion -packageName 'dependent' -version '0.1.1' -packageCargoToml $packageCargo -rootCargoToml $rootCargo - $new | Should -Be '0.1.1' - (Get-Content $packageCargo -Raw) | Should -Match 'version\s*=\s*"0\.1\.1"' - } - - It 'updates the [workspace.dependencies] entry for the version-changed package' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'uvc-root') - $packageCargo = Join-Path $ws.Path 'crates\dependency\Cargo.toml' - $rootCargo = Join-Path $ws.Path 'Cargo.toml' - Update-PackageVersion -packageName 'dependency' -version '0.2.1' -packageCargoToml $packageCargo -rootCargoToml $rootCargo | Out-Null - $rootContent = Get-Content $rootCargo -Raw - $rootContent | Should -Match 'dependency\s*=\s*\{[^}]*version\s*=\s*"0\.2\.1"' - # And dependent's version line in the same root table is unchanged. - $rootContent | Should -Match 'dependent\s*=\s*\{[^}]*version\s*=\s*"0\.1\.0"' - } - - It 'preserves inline dependency version when the [package] version changes' { - # Earlier, the package-level regex was `(?<=version\s*=\s*")[^"]+` applied - # via `-replace`, which clobbers every `version = "..."` in the file — - # including any inline workspace-dep declarations like - # `dep = { path = "...", version = "x.y.z" }`. Phase 8 fix scopes the - # replacement to the [package] table only; this test pins the corrected - # behavior. - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'uvc-inline-dep') - - # Replace the dependent Cargo.toml with one that declares dependency inline - # (instead of via .workspace = true). - $dependentCargo = Join-Path $ws.Path 'crates\dependent\Cargo.toml' - Set-Content -Path $dependentCargo -Value @" -[package] -name = "dependent" -version = "0.1.0" -edition = "2021" -publish = true - -[lib] - -[dependencies] -dependency = { path = "../dependency", version = "0.2.0" } -"@ -NoNewline - - $rootCargo = Join-Path $ws.Path 'Cargo.toml' - Update-PackageVersion -packageName 'dependent' -version '0.1.1' -packageCargoToml $dependentCargo -rootCargoToml $rootCargo | Out-Null - - $content = Get-Content $dependentCargo -Raw - # [package] version was updated. - $content | Should -Match 'name\s*=\s*"dependent"[^\[]*?version\s*=\s*"0\.1\.1"' - # Inline dependency dep's declared version is preserved. - if ($content -match 'dependency\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"') { - $Matches[1] | Should -Be '0.2.0' -Because 'Update-PackageVersion must not rewrite inline workspace-dep versions.' - } else { - throw "Could not extract dependency version from rewritten Cargo.toml: $content" - } - } - - It 'preserves rust-version when the [package] version changes' { - # The naive `\bversion` regex was vulnerable to matching `rust-version` - # because `-` is a non-word character (word boundary lies between `-` and - # `version`). The shared CargoPackageVersionRegex anchors to line start, - # so `rust-version = "..."` is no longer confused with the package's - # version literal. Pin both orderings (rust-version before vs after). - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'uvc-rust-version') - - $packageCargo = Join-Path $ws.Path 'crates\dependent\Cargo.toml' - Set-Content -Path $packageCargo -NoNewline -Value @" -[package] -name = "dependent" -rust-version = "1.88" -version = "0.1.0" -edition = "2021" -publish = true - -[lib] -"@ - - $rootCargo = Join-Path $ws.Path 'Cargo.toml' - Update-PackageVersion -packageName 'dependent' -version '0.1.1' -packageCargoToml $packageCargo -rootCargoToml $rootCargo | Out-Null - - $content = Get-Content $packageCargo -Raw - $content | Should -Match 'rust-version\s*=\s*"1\.88"' -Because 'rust-version must be left alone.' - $content | Should -Match '(?m)^[ \t]*version\s*=\s*"0\.1\.1"' - } - - It 'does not rewrite the inline version of a sibling crate whose name has the target as a suffix' { - # The root-Cargo.toml rewrite previously used an un-anchored lookbehind: - # `(?<=NAME\s*=\s*\{[^\}]*?version\s*=\s*")`. Releasing e.g. `bar` would - # also match `foo_bar = { ..., version = "..." }` because the regex - # engine can satisfy the lookbehind by matching `bar` as a suffix of - # `foo_bar`. The fix anchors the lookbehind to the start of a line - # under (?m). This test pins the corrected behaviour by constructing - # an ad-hoc workspace with a deliberately colliding pair. - Reset-ReleaseScriptCaches - $spec = @{ - Packages = @( - @{ Name = 'bar'; Version = '0.1.0' } - @{ Name = 'foo_bar'; Version = '0.2.0' } - ) - } - $ws = New-SyntheticWorkspace -Spec $spec -Path (Join-Path $TestDrive 'uvc-suffix') - - $packageCargo = Join-Path $ws.Path 'crates\bar\Cargo.toml' - $rootCargo = Join-Path $ws.Path 'Cargo.toml' - Update-PackageVersion -packageName 'bar' -version '0.1.1' -packageCargoToml $packageCargo -rootCargoToml $rootCargo | Out-Null - - $rootContent = Get-Content $rootCargo -Raw - $rootContent | Should -Match '(?m)^bar\s*=\s*\{[^}]*version\s*=\s*"0\.1\.1"' -Because 'The target crate''s inline version must be updated.' - $rootContent | Should -Match '(?m)^foo_bar\s*=\s*\{[^}]*version\s*=\s*"0\.2\.0"' -Because 'A sibling crate whose name ends in the target name must not be rewritten.' - } -} - -# -------------------------------------------------------------------------- -# Invoke-ResolvedRelease — atomic multi-package on-disk product. -# -# Pins the contract that, when a multi-package plan executes successfully, -# every artefact for every release-set member is written: per-package -# Cargo.toml (new [package] version), workspace root Cargo.toml (new -# inline-dep version in [workspace.dependencies]), per-package CHANGELOG -# (new version section prepended, with cascade-from-dependency bullets on -# cascade-source members), and Update-Readme invoked once per member. -# -# Unit-level coverage of each helper proves the helpers work in isolation; -# this test pins that Invoke-ResolvedRelease wires them all into the per- -# folder loop so a regression that wrote Cargo.toml but skipped CHANGELOG -# (or vice versa) is caught — the regression mode the test-suite review -# identified as the most plausible silent-correctness gap. -# -------------------------------------------------------------------------- - -Describe 'Invoke-ResolvedRelease: atomic multi-package on-disk product' { - BeforeAll { - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') - } - - It 'writes Cargo.toml + workspace Cargo.toml + CHANGELOG + Update-Readme call for every plan member' { - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'invoke-resolved-release-atomic') - - # Replace the bare `## [Unreleased]` placeholder (no trailing newline) - # with a body that the Extract-UnreleasedSection regex can actually - # match, so the new version section folds the manually-curated note - # in — exercising the most common production shape. - $dependencyChangelog = Join-Path $ws.Path 'crates\dependency\CHANGELOG.md' - $dependentChangelog = Join-Path $ws.Path 'crates\dependent\CHANGELOG.md' - $changelogBody = @( - '# Changelog', - '', - '## [Unreleased]', - '', - '- manually curated note', - '' - ) -join "`n" - Set-Content -LiteralPath $dependencyChangelog -Value $changelogBody -NoNewline - Set-Content -LiteralPath $dependentChangelog -Value $changelogBody -NoNewline - - # Touch each package with a conventional-commit-formatted message so - # Write-Changelog has something to fold into the new section — also - # proves Write-Changelog ran (a no-modification call would early- - # return with a warning and leave the CHANGELOG untouched). - $ws.ModifySource('dependency', '// dependency feature') - $ws.AddCommit('feat(dependency): add dependency feature') - $ws.ModifySource('dependent', '// dependent tweak') - $ws.AddCommit('feat(dependent): use new dependency feature') - - $workspaceBaseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $rootCargo = Join-Path $ws.Path 'Cargo.toml' - - # Hand-build the ResolvedReleaseSet that Resolve-ReleaseSet would - # produce for: -Packages dependency@non-breaking. On 0.x, non-breaking - # collapses to a patch bump (0.2.0 -> 0.2.1). Cascade reaches - # dependent as 'non-breaking' (no cargo_check_external_types declared - # on either side, so the dep is treated as exposing; the dependency - # non-breaking is not breaking, so the exposing change type carries - # through to dependent non-breaking → 0.1.0 -> 0.1.1). - $dependencyCascadeReasons = New-Object 'System.Collections.Generic.List[object]' - $dependentCascadeReasons = New-Object 'System.Collections.Generic.List[object]' - [void]$dependentCascadeReasons.Add([pscustomobject]@{ - Target = 'dependency' - Breaking = $false - }) - - $resolved = [ordered]@{ - dependency = [pscustomobject]@{ - Folder = 'dependency' - Name = 'dependency' - CurrentVersion = '0.2.0' - EffectiveTargetVersion = '0.2.1' - EffectiveChangeType = 'non-breaking' - Source = 'user' - AutoUpgraded = $false - PinHonoredAgainstCascade = $false - CascadeReasons = $dependencyCascadeReasons - } - dependent = [pscustomobject]@{ - Folder = 'dependent' - Name = 'dependent' - CurrentVersion = '0.1.0' - EffectiveTargetVersion = '0.1.1' - EffectiveChangeType = 'non-breaking' - Source = 'cascade' - AutoUpgraded = $false - PinHonoredAgainstCascade = $false - CascadeReasons = $dependentCascadeReasons - } - } - - # Mock Update-Readme so we can assert it was invoked once per member - # without depending on cargo-doc2readme being installed or a real - # README.j2 template existing in the synthetic workspace. The other - # helpers (Update-PackageVersion / Write-Changelog) are exercised - # for real so their on-disk side effects are observable. - Mock -CommandName Update-Readme -MockWith { } -Verifiable:$false - - Push-Location $ws.Path - try { - $releases = @(Invoke-ResolvedRelease ` - -RepoRoot $ws.Path ` - -RootCargoToml $rootCargo ` - -ResolvedReleaseSet $resolved ` - -WorkspaceBaseline $workspaceBaseline) - } finally { - Pop-Location - } - - # --- Returned records: topo order (deps first), one per release-set member. - $releases.Count | Should -Be 2 - $releases[0].Package | Should -Be 'dependency' - $releases[0].OldVersion | Should -Be '0.2.0' - $releases[0].NewVersion | Should -Be '0.2.1' - $releases[1].Package | Should -Be 'dependent' - $releases[1].OldVersion | Should -Be '0.1.0' - $releases[1].NewVersion | Should -Be '0.1.1' - - # --- Per-package Cargo.toml: the [package] version line is rewritten, - # other [package] fields are preserved verbatim. - $dependencyCargo = Get-Content (Join-Path $ws.Path 'crates\dependency\Cargo.toml') -Raw - $dependentCargo = Get-Content (Join-Path $ws.Path 'crates\dependent\Cargo.toml') -Raw - $dependencyCargo | Should -Match '(?m)^version\s*=\s*"0\.2\.1"' - $dependencyCargo | Should -Not -Match '(?m)^version\s*=\s*"0\.2\.0"' - $dependencyCargo | Should -Match '(?m)^name\s*=\s*"dependency"' - $dependentCargo | Should -Match '(?m)^version\s*=\s*"0\.1\.1"' - $dependentCargo | Should -Not -Match '(?m)^version\s*=\s*"0\.1\.0"' - $dependentCargo | Should -Match '(?m)^name\s*=\s*"dependent"' - # Dependency declaration in dependent Cargo.toml is preserved (workspace inheritance). - $dependentCargo | Should -Match '(?m)^dependency\.workspace\s*=\s*true' - - # --- Root Cargo.toml: both [workspace.dependencies] entries are updated. - $rootContent = Get-Content $rootCargo -Raw - $rootContent | Should -Match '(?m)^dependency\s*=\s*\{[^}]*version\s*=\s*"0\.2\.1"' - $rootContent | Should -Match '(?m)^dependent\s*=\s*\{[^}]*version\s*=\s*"0\.1\.1"' - $rootContent | Should -Not -Match '(?m)^dependency\s*=\s*\{[^}]*version\s*=\s*"0\.2\.0"' - $rootContent | Should -Not -Match '(?m)^dependent\s*=\s*\{[^}]*version\s*=\s*"0\.1\.0"' - - # --- Per-package CHANGELOG: new version section was prepended. - $today = (Get-Date).ToString('yyyy-MM-dd') - $dependencyChangelogText = Get-Content $dependencyChangelog -Raw - $dependentChangelogText = Get-Content $dependentChangelog -Raw - - # Top-level `# Changelog` header is preserved. - $dependencyChangelogText | Should -Match '(?m)^# Changelog' - $dependentChangelogText | Should -Match '(?m)^# Changelog' - - # New version section header (with today's date) appears in both. - $dependencyChangelogText | Should -Match ('(?m)^## \[0\.2\.1\] - ' + [regex]::Escape($today)) - $dependentChangelogText | Should -Match ('(?m)^## \[0\.1\.1\] - ' + [regex]::Escape($today)) - - # The manually-curated `## [Unreleased]` body line was folded into - # the new version section (and the now-empty Unreleased heading was - # stripped — Extract-UnreleasedSection consumed it). - $dependencyChangelogText | Should -Match 'manually curated note' - $dependentChangelogText | Should -Match 'manually curated note' - $dependencyChangelogText | Should -Not -Match '(?m)^## \[Unreleased\]' - $dependentChangelogText | Should -Not -Match '(?m)^## \[Unreleased\]' - - # Conventional-commit bullets from the feat(...) commits are grouped - # under a `Features` section header. - $dependencyChangelogText | Should -Match 'Features' - $dependencyChangelogText | Should -Match 'add dependency feature' - $dependentChangelogText | Should -Match 'Features' - $dependentChangelogText | Should -Match 'use new dependency feature' - - # dependent is cascade-from-dependency: a Maintenance section with - # a `Now requires of ` bullet must be emitted even - # though the package only had a feat commit (cascade bullets live in - # their own section, separate from the conventional-commit ones). - $dependentChangelogText | Should -Match '🔧 Maintenance' - $dependentChangelogText | Should -Match 'Now requires `0\.2\.1` of `dependency`' - - # --- Update-Readme: invoked once per release-set member, with the - # right per-package arguments. This is the README half of the - # atomicity contract — Update-Readme is the only per-folder side - # effect that doesn't produce an on-disk artefact in this fixture - # (no README.j2 template, so the real implementation warns and - # returns), and asserting the call count + arguments closes the - # "wrote Cargo.toml but skipped README regen" regression mode. - Should -Invoke -CommandName Update-Readme -Times 2 -Exactly - Should -Invoke -CommandName Update-Readme -Times 1 -Exactly ` - -ParameterFilter { $packageName -eq 'dependency' } - Should -Invoke -CommandName Update-Readme -Times 1 -Exactly ` - -ParameterFilter { $packageName -eq 'dependent' } - - # No README.md was written by the real path either (no template). - (Test-Path (Join-Path $ws.Path 'crates\dependency\README.md')) | Should -BeFalse - (Test-Path (Join-Path $ws.Path 'crates\dependent\README.md')) | Should -BeFalse - } - - It 'names the DIRECT dependency (not the root cause) in an indirect dependent''s changelog (ADO bug 7536096)' { - # Linear3: a -> b -> c (a depends on b, b depends on c). Releasing 'c' - # cascades to BOTH b and a. 'a' depends DIRECTLY on b only — so its - # changelog must say "Now requires of b", never - # "of c" (the root cause it does not directly depend on). - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive 'invoke-resolved-release-indirect') - - $workspaceBaseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $rootCargo = Join-Path $ws.Path 'Cargo.toml' - - # Plan: release c@patch; cascade lifts b and a by patch. CascadeReasons - # carry the ROOT cause (c) for both b and a — the OLD bug let those leak - # into a's changelog. The executor must instead derive bullets from each - # crate's DIRECT deps in the plan. - $aReasons = New-Object 'System.Collections.Generic.List[object]' - [void]$aReasons.Add([pscustomobject]@{ Target = 'c'; Breaking = $false }) - $bReasons = New-Object 'System.Collections.Generic.List[object]' - [void]$bReasons.Add([pscustomobject]@{ Target = 'c'; Breaking = $false }) - $cReasons = New-Object 'System.Collections.Generic.List[object]' - - $resolved = [ordered]@{ - a = [pscustomobject]@{ - Folder = 'a'; Name = 'a'; CurrentVersion = '0.1.0'; EffectiveTargetVersion = '0.1.1' - EffectiveChangeType = 'patch'; Source = 'cascade'; AutoUpgraded = $false - PinHonoredAgainstCascade = $false; CascadeReasons = $aReasons - } - b = [pscustomobject]@{ - Folder = 'b'; Name = 'b'; CurrentVersion = '0.2.0'; EffectiveTargetVersion = '0.2.1' - EffectiveChangeType = 'patch'; Source = 'cascade'; AutoUpgraded = $false - PinHonoredAgainstCascade = $false; CascadeReasons = $bReasons - } - c = [pscustomobject]@{ - Folder = 'c'; Name = 'c'; CurrentVersion = '0.3.0'; EffectiveTargetVersion = '0.3.1' - EffectiveChangeType = 'patch'; Source = 'user'; AutoUpgraded = $false - PinHonoredAgainstCascade = $false; CascadeReasons = $cReasons - } - } - - Mock -CommandName Update-Readme -MockWith { } -Verifiable:$false - - Push-Location $ws.Path - try { - $null = @(Invoke-ResolvedRelease ` - -RepoRoot $ws.Path ` - -RootCargoToml $rootCargo ` - -ResolvedReleaseSet $resolved ` - -WorkspaceBaseline $workspaceBaseline) - } finally { - Pop-Location - } - - $aChangelog = Get-Content (Join-Path $ws.Path 'crates\a\CHANGELOG.md') -Raw - $bChangelog = Get-Content (Join-Path $ws.Path 'crates\b\CHANGELOG.md') -Raw - $cChangelog = Get-Content (Join-Path $ws.Path 'crates\c\CHANGELOG.md') -Raw - - # The indirect dependent 'a' names its DIRECT dep 'b' at b's NEW version. - $aChangelog | Should -Match 'Now requires `0\.2\.1` of `b`' - # It must NOT name the root-cause crate 'c' (the bug being fixed). - $aChangelog | Should -Not -Match 'of `c`' - - # The direct dependent 'b' names its direct dep 'c'. - $bChangelog | Should -Match 'Now requires `0\.3\.1` of `c`' - - # The released root 'c' has no direct deps in the set → no cascade bullet. - $cChangelog | Should -Not -Match 'Now requires' - } - - It 'puts the cascade bullet under Maintenance for a breaking user target that is a non-breaking cascade dependent (ADO 7536096 reviewer counterexample)' { - # Linear2: dependent -> dependency. Release dependency@patch together - # with dependent@breaking. 'dependent' is BOTH a user target (breaking, - # 1.0.0 -> 2.0.0) AND a cascade dependent of dependency (the edge is - # non-breaking). The "Now requires" bullet must land under 🔧 Maintenance - # (driven by the per-edge cascade flag), NOT ⚠️ Breaking. - Reset-ReleaseScriptCaches - $ws = New-SyntheticWorkspace -Spec @{ - Packages = @( - @{ Name = 'dependent'; Version = '1.0.0'; Deps = @(@{ Name = 'dependency' }) } - @{ Name = 'dependency'; Version = '0.2.0' } - ) - } -Path (Join-Path $TestDrive 'invoke-resolved-release-mixed') - - $workspaceBaseline = @(Get-WorkspacePackages -repoRoot $ws.Path) - $rootCargo = Join-Path $ws.Path 'Cargo.toml' - - # CascadeReasons carries the per-edge flag: dependency->dependent is - # non-breaking, so the recorded reason is Breaking=$false even though - # the dependent's own EffectiveChangeType is breaking. - $dependentReasons = New-Object 'System.Collections.Generic.List[object]' - [void]$dependentReasons.Add([pscustomobject]@{ Target = 'dependency'; Breaking = $false }) - - $resolved = [ordered]@{ - dependency = [pscustomobject]@{ - Folder = 'dependency'; Name = 'dependency'; CurrentVersion = '0.2.0'; EffectiveTargetVersion = '0.2.1' - EffectiveChangeType = 'patch'; Source = 'user'; AutoUpgraded = $false - PinHonoredAgainstCascade = $false; CascadeReasons = (New-Object 'System.Collections.Generic.List[object]') - } - dependent = [pscustomobject]@{ - Folder = 'dependent'; Name = 'dependent'; CurrentVersion = '1.0.0'; EffectiveTargetVersion = '2.0.0' - EffectiveChangeType = 'breaking'; Source = 'user'; AutoUpgraded = $false - PinHonoredAgainstCascade = $false; CascadeReasons = $dependentReasons - } - } - - Mock -CommandName Update-Readme -MockWith { } -Verifiable:$false - - Push-Location $ws.Path - try { - $null = @(Invoke-ResolvedRelease ` - -RepoRoot $ws.Path -RootCargoToml $rootCargo ` - -ResolvedReleaseSet $resolved -WorkspaceBaseline $workspaceBaseline) - } finally { - Pop-Location - } - - $dependentChangelog = Get-Content (Join-Path $ws.Path 'crates\dependent\CHANGELOG.md') -Raw - - $dependentChangelog | Should -Match 'Now requires `0\.2\.1` of `dependency`' - $dependentChangelog | Should -Match '🔧 Maintenance' - $dependentChangelog | Should -Not -Match '⚠️ Breaking' - } -} - diff --git a/scripts/tests/Pester/integration/Topology-Presets.Tests.ps1 b/scripts/tests/Pester/integration/Topology-Presets.Tests.ps1 deleted file mode 100644 index 652e734cf..000000000 --- a/scripts/tests/Pester/integration/Topology-Presets.Tests.ps1 +++ /dev/null @@ -1,169 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -BeforeAll { - . (Join-Path $PSScriptRoot '..\_common\TestHelpers.ps1') - . (Join-Path $PSScriptRoot '..\_common\New-SyntheticWorkspace.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\releasing.ps1') -} - -Describe 'Topology presets (smoke)' { - BeforeEach { - Reset-ReleaseScriptCaches - } - - Context 'Linear2' { - It 'detects modified dependency' { - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'linear2') - $ws.ModifySource('dependency') - $ws.AddCommit('dependency edit') - $ws.SetVersion('dependent', '0.1.1') - $ws.AddCommit('change dependent') - # dependent's release artefact must have source modifications past - # its baseline for the LIVE filter to use it as a BFS root. - $ws.ModifySource('dependent') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - $up = $findings | Where-Object { $_.Folder -eq 'dependency' } - $up | Should -Not -BeNullOrEmpty - $up.DependencyChains | Should -HaveCount 1 - $up.DependencyChains[0] -join ',' | Should -Be 'dependent,dependency' - } - } - - Context 'Linear3' { - It 'reaches modified leaf through unchanged middle' { - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive 'linear3') - $ws.ModifySource('c') - $ws.AddCommit('c edit') - $ws.SetVersion('a', '0.1.1') - $ws.AddCommit('change a') - $ws.ModifySource('a') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - $cf = $findings | Where-Object { $_.Folder -eq 'c' } - $cf | Should -Not -BeNullOrEmpty - $cf.DependencyChains[0] -join ',' | Should -Be 'a,b,c' - } - } - - Context 'Linear4' { - It 'BFS depth 4 reaches leaf 3 hops dependency' { - $ws = New-SyntheticWorkspace -Preset Linear4 -Path (Join-Path $TestDrive 'linear4') - $ws.ModifySource('d') - $ws.AddCommit('d edit') - $ws.SetVersion('a', '0.1.1') - $ws.AddCommit('change a') - $ws.ModifySource('a') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - $df = $findings | Where-Object { $_.Folder -eq 'd' } - $df | Should -Not -BeNullOrEmpty - $df.DependencyChains[0] -join ',' | Should -Be 'a,b,c,d' - } - } - - Context 'Diamond4' { - It 'aggregates two distinct chains to the same modified dep' { - $ws = New-SyntheticWorkspace -Preset Diamond4 -Path (Join-Path $TestDrive 'diamond4') - $ws.ModifySource('bottom') - $ws.AddCommit('bottom edit') - $ws.SetVersion('top', '0.1.1') - $ws.AddCommit('change top') - $ws.ModifySource('top') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - $bf = $findings | Where-Object { $_.Folder -eq 'bottom' } - $bf | Should -Not -BeNullOrEmpty - $bf.DependencyChains.Count | Should -BeGreaterOrEqual 1 - } - } - - Context 'Macros3' { - It 'mirrors thread_aware_macros_impl chain' { - $ws = New-SyntheticWorkspace -Preset Macros3 -Path (Join-Path $TestDrive 'macros3') - $ws.ModifySource('macros_impl') - $ws.AddCommit('macros_impl edit') - $ws.SetVersion('user', '0.1.1') - $ws.AddCommit('change user') - $ws.ModifySource('user') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - $mf = $findings | Where-Object { $_.Folder -eq 'macros_impl' } - $mf | Should -Not -BeNullOrEmpty - $mf.DependencyChains[0] -join ',' | Should -Be 'user,macros,macros_impl' - } - } - - Context 'FanOut5' { - It 'one shared dependency reported once across multiple version-changed dependents' { - $ws = New-SyntheticWorkspace -Preset FanOut5 -Path (Join-Path $TestDrive 'fanout5') - $ws.ModifySource('shared_dependency') - $ws.AddCommit('shared edit') - $ws.SetVersion('user1', '0.1.1') - $ws.SetVersion('user2', '0.2.1') - $ws.SetVersion('user3', '0.3.1') - $ws.AddCommit('change users') - $ws.ModifySource('user1') - $ws.ModifySource('user2') - $ws.ModifySource('user3') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - $sh = $findings | Where-Object { $_.Folder -eq 'shared_dependency' } - $sh | Should -Not -BeNullOrEmpty - $sh.DependencyChains.Count | Should -BeGreaterOrEqual 3 - } - } - - Context 'FanInOut5' { - It 'detects dependency above target while target has dependent relations' { - $ws = New-SyntheticWorkspace -Preset FanInOut5 -Path (Join-Path $TestDrive 'faninout5') - $ws.ModifySource('dependency_a') - $ws.ModifySource('dependency_b') - $ws.AddCommit('dependency edits') - $ws.SetVersion('target', '0.3.1') - $ws.AddCommit('change target') - $ws.ModifySource('target') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - $folders = @($findings | ForEach-Object Folder) - $folders | Should -Contain 'dependency_a' - $folders | Should -Contain 'dependency_b' - - $dependents = Get-AllTransitiveDependents -packageName 'target' -repoRoot $ws.Path - ($dependents | Sort-Object) -join ',' | Should -Be 'dependent_x,dependent_y' - } - } - - Context 'Mixed6' { - It 'filters dev-deps and publish=false but keeps normal deps' { - $ws = New-SyntheticWorkspace -Preset Mixed6 -Path (Join-Path $TestDrive 'mixed6') - $ws.ModifySource('dependency_a') - $ws.ModifySource('dependency_b') - $ws.ModifySource('utility') - $ws.AddCommit('dependency edits') - $ws.SetVersion('target', '0.1.1') - $ws.AddCommit('change target') - $ws.ModifySource('target') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - $folders = @($findings | ForEach-Object Folder) - $folders | Should -Contain 'dependency_b' - $folders | Should -Not -Contain 'dependency_a' # dev-dep, not surfaced - $folders | Should -Not -Contain 'utility' # publish=false - } - } - - Context 'Detached' { - It 'modified package in component B never surfaces from a release in component A' { - $ws = New-SyntheticWorkspace -Preset Detached -Path (Join-Path $TestDrive 'detached') - $ws.ModifySource('delta') - $ws.AddCommit('delta edit') - $ws.SetVersion('alpha', '0.1.1') - $ws.AddCommit('change alpha') - - $findings = Get-UnreleasedModifiedDependencies -RepoRoot $ws.Path -ResolvedReleaseSet (New-ResolvedReleaseSetFromBaseRef -RepoRoot $ws.Path -BaseRef 'HEAD~2') - @($findings).Count | Should -Be 0 - } - } -} diff --git a/scripts/tests/Pester/scenarios/S00-smoke-fresh-release.scenario.psd1 b/scripts/tests/Pester/scenarios/S00-smoke-fresh-release.scenario.psd1 deleted file mode 100644 index 0790d149a..000000000 --- a/scripts/tests/Pester/scenarios/S00-smoke-fresh-release.scenario.psd1 +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S00-smoke-fresh-release' - Description = 'No-cascade smoke test: releasing a leaf package with no dependents and no dependency modifications produces a single release record and raises no prompts. Smoke test for the scenario runner.' - - Workspace = @{ Preset = 'Linear2' } # dependent -> dependency - - History = @( - # No modifications: the post-release scan should have nothing to report. - ) - - Run = @{ - # 'dependent' has no dependents, so no cascade. Dependency is clean. - Packages = @('dependent@patch') - Answers = @() - } - - Expect = @{ - Released = @( - @{ Package = 'dependent'; To = '0.1.1' } - ) - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S01-clean-dependency-no-prompts.scenario.psd1 b/scripts/tests/Pester/scenarios/S01-clean-dependency-no-prompts.scenario.psd1 deleted file mode 100644 index 1ae405690..000000000 --- a/scripts/tests/Pester/scenarios/S01-clean-dependency-no-prompts.scenario.psd1 +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S01-clean-dependency-no-prompts' - Description = 'Linear3 with no dependency modifications produces a clean release: only the released package appears, and no prompts are raised.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @() - - Run = @{ - Packages = @('a@patch') - Answers = @() - } - - Expect = @{ - Released = @( - @{ Package = 'a'; To = '0.1.1' } - ) - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S02-accept-and-decline.scenario.psd1 b/scripts/tests/Pester/scenarios/S02-accept-and-decline.scenario.psd1 deleted file mode 100644 index b9de87033..000000000 --- a/scripts/tests/Pester/scenarios/S02-accept-and-decline.scenario.psd1 +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S02-accept-and-decline' - Description = 'Linear3 with both dependency packages modified: user accepts b (which releases as minor) and declines c. Final release set = a + b; c stays unreleased.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @( - @{ Op = 'ModifySource'; Package = 'a' } - @{ Op = 'ModifySource'; Package = 'b' } - @{ Op = 'ModifySource'; Package = 'c' } - @{ Op = 'AddCommit'; Message = 'dependency edits' } - ) - - Run = @{ - Packages = @('a@patch') - Answers = @( - @{ Match = "Choose option for 'b'"; Reply = '4' } # Non-breaking - @{ Match = "Choose option for 'c'"; Reply = '2' } # No material changes - ) - } - - Expect = @{ - # In 0.x semver convention (per Get-NextVersion), a "non-breaking" change - # on 0.2.0 is patch-style → 0.2.1 (true breaking is "breaking" → 0.3.0). - # b's cascade to a requires 0.1.1 which a already satisfies (from the - # initial patch), so a stays at 0.1.1 (bullet-only). - Released = @( - @{ Package = 'a'; To = '0.1.1' } - @{ Package = 'b'; To = '0.2.1' } - ) - PromptsRaised = @( - "Choose option for 'b'" - "Choose option for 'c'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S03-decline-all.scenario.psd1 b/scripts/tests/Pester/scenarios/S03-decline-all.scenario.psd1 deleted file mode 100644 index 29b7e77d1..000000000 --- a/scripts/tests/Pester/scenarios/S03-decline-all.scenario.psd1 +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S03-decline-all' - Description = 'Linear3 with both dependency packages modified: user declines both. Final release is the originally requested package only; both dependency findings stay unreleased.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @( - @{ Op = 'ModifySource'; Package = 'a' } - @{ Op = 'ModifySource'; Package = 'b' } - @{ Op = 'ModifySource'; Package = 'c' } - @{ Op = 'AddCommit'; Message = 'dependency edits' } - ) - - Run = @{ - Packages = @('a@patch') - Answers = @( - @{ Match = "Choose option for 'b'"; Reply = '2' } # No material changes - @{ Match = "Choose option for 'c'"; Reply = '2' } # No material changes - ) - } - - Expect = @{ - Released = @( - @{ Package = 'a'; To = '0.1.1' } - ) - PromptsRaised = @( - "Choose option for 'b'" - "Choose option for 'c'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S05-diamond4-aggregation.scenario.psd1 b/scripts/tests/Pester/scenarios/S05-diamond4-aggregation.scenario.psd1 deleted file mode 100644 index af8e5b67c..000000000 --- a/scripts/tests/Pester/scenarios/S05-diamond4-aggregation.scenario.psd1 +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S05-diamond4-aggregation' - Description = 'Diamond4 (top -> left, right; left, right -> bottom): release top, modify bottom only. Bottom is reachable via two paths (top->left->bottom and top->right->bottom); both chains should be aggregated under a single finding. User accepts, bottom releases with a single release.' - - Workspace = @{ Preset = 'Diamond4' } - - History = @( - @{ Op = 'ModifySource'; Package = 'top' } - @{ Op = 'ModifySource'; Package = 'bottom' } - @{ Op = 'AddCommit'; Message = 'bottom edits' } - ) - - Run = @{ - Packages = @('top@patch') - Answers = @( - # On 0.x.y the menu hides option 5 (patch) because it would be numerically - # identical to option 4 (non-breaking change), so we pick '4' to drive - # the same 0.x.y -> 0.x.(y+1) increment. - @{ Match = "Choose option for 'bottom'"; Reply = '4' } # Non-breaking - ) - } - - Expect = @{ - # top is released per request, bottom is released via the prompt, and bottom's - # cascade pulls in its dependents (left, right). top is in release set already. - Released = @( - @{ Package = 'top'; To = '0.1.1' } - @{ Package = 'bottom'; To = '0.4.1' } - @{ Package = 'left'; To = '0.2.1' } - @{ Package = 'right'; To = '0.3.1' } - ) - PromptsRaised = @( - "Choose option for 'bottom'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S06-invariant-b-elevation-review.scenario.psd1 b/scripts/tests/Pester/scenarios/S06-invariant-b-elevation-review.scenario.psd1 deleted file mode 100644 index c14d772ca..000000000 --- a/scripts/tests/Pester/scenarios/S06-invariant-b-elevation-review.scenario.psd1 +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S06-invariant-b-elevation-review' - Description = 'Multi-dependent cascade exercising Invariant B end-to-end. User explicitly releases ''b'' as a non-breaking change. The cascade pulls ''a'' (which depends on ''b''), ''alpha'' (which depends on ''b''), and ''zeta'' (which depends on ''a'') into the release set, all at non-breaking. Because ''a'' ALSO has pre-existing source modifications AND its cascade-applied change type is below breaking, the plan-review surfaces ''a'' for elevation review. The user ignores the elevation; ''a'' stays at the cascade-applied 0.3.1. - -Validates two contracts simultaneously: (1) cascade-only members with no modifications are NOT prompted (Invariant A — verified by ''alpha'' and ''zeta'' going through without a prompt), and (2) cascade members WITH modifications below the breaking ceiling ARE prompted (Invariant B).' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'zeta'; Version = '0.1.0'; Deps = @(@{ Name = 'a' }) } - @{ Name = 'alpha'; Version = '0.2.0'; Deps = @(@{ Name = 'b' }) } - @{ Name = 'a'; Version = '0.3.0'; Deps = @(@{ Name = 'b' }) } - @{ Name = 'b'; Version = '0.4.0' } - ) - } - } - - History = @( - @{ Op = 'ModifySource'; Package = 'a' } - @{ Op = 'ModifySource'; Package = 'b' } - @{ Op = 'AddCommit'; Message = 'dependency edits' } - ) - - Run = @{ - Packages = @('b@nonbreaking') - Answers = @( - # Invariant B: 'a' was cascade-pulled with a non-breaking change AND - # has pre-existing modifications, so plan-review surfaces it for - # elevation review. User answers '2' (ignore — keep the - # cascade-applied change type). - @{ Match = "Choose option for 'a'"; Reply = '2' } # No material changes - ) - } - - Expect = @{ - # b is user-source, released as non-breaking on 0.x.y → 0.4.1. - # a is cascade-released as non-breaking on 0.x.y → 0.3.1 (user accepted). - # alpha is cascade-released as non-breaking on 0.x.y → 0.2.1. - # zeta is cascade-released as non-breaking on 0.x.y → 0.1.1. - Released = @( - @{ Package = 'b'; To = '0.4.1' } - @{ Package = 'a'; To = '0.3.1' } - @{ Package = 'alpha'; To = '0.2.1' } - @{ Package = 'zeta'; To = '0.1.1' } - ) - PromptsRaised = @( - "Choose option for 'a'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S07-view-diff-then-decide.scenario.psd1 b/scripts/tests/Pester/scenarios/S07-view-diff-then-decide.scenario.psd1 deleted file mode 100644 index 1c6e97894..000000000 --- a/scripts/tests/Pester/scenarios/S07-view-diff-then-decide.scenario.psd1 +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S07-view-diff-then-decide' - Description = 'User exercises the View Diff option (menu choice 1) for a finding before deciding. After viewing the diff the script re-prompts on the same package without re-rendering the menu, and the user picks minor (option 4) for b, then ignores c. Validates that choice 1 re-prompts on the same package rather than advancing.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @( - @{ Op = 'ModifySource'; Package = 'a' } - @{ Op = 'ModifySource'; Package = 'b' } - @{ Op = 'ModifySource'; Package = 'c' } - @{ Op = 'AddCommit'; Message = 'dependency edits' } - ) - - Run = @{ - Packages = @('a@patch') - Answers = @( - # First prompt for b: view the diff. - @{ Match = "Choose option for 'b'"; Reply = '1' } # View diff - # Script re-prompts for b after diff (without re-rendering the - # menu); this time choose minor. - @{ Match = "Choose option for 'b'"; Reply = '4' } # Non-breaking - # Next iteration prompts for c; user ignores. - @{ Match = "Choose option for 'c'"; Reply = '2' } # No material changes - ) - } - - Expect = @{ - # b accepted as minor (0.x: patch-style) → 0.2.1. a's cascade bullet-only at 0.1.1. - # c is declined; no entry in releases. - Released = @( - @{ Package = 'a'; To = '0.1.1' } - @{ Package = 'b'; To = '0.2.1' } - ) - PromptsRaised = @( - "Choose option for 'b'" - "Choose option for 'b'" - "Choose option for 'c'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S08-invalid-then-valid-input.scenario.psd1 b/scripts/tests/Pester/scenarios/S08-invalid-then-valid-input.scenario.psd1 deleted file mode 100644 index f3c783f3c..000000000 --- a/scripts/tests/Pester/scenarios/S08-invalid-then-valid-input.scenario.psd1 +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S08-invalid-then-valid-input' - Description = 'User provides invalid menu inputs (whole-string check) and empty input before settling on a valid choice. The prompt is repeated each time; no answer is consumed without the menu being shown. Validates strict input validation in Get-PackageReleaseDecision.' - - Workspace = @{ Preset = 'Linear2' } # dependent -> dependency - - History = @( - @{ Op = 'ModifySource'; Package = 'dependent' } - @{ Op = 'ModifySource'; Package = 'dependency' } - @{ Op = 'AddCommit'; Message = 'dependency edits' } - ) - - Run = @{ - Packages = @('dependent@patch') - Answers = @( - # "12" starts with valid digit but is a multi-character non-option — must be rejected as a whole. - @{ Match = "Choose option for 'dependency'"; Reply = '12' } # Invalid (re-prompts) - # Empty input silently re-prompts. - @{ Match = "Choose option for 'dependency'"; Reply = '' } # Empty (re-prompts) - # Finally a valid choice. On 0.x.y the menu offers [1-4] only (option 5 - # is hidden because it would be numerically identical to option 4), so - # we drive the accept path via '4'. - @{ Match = "Choose option for 'dependency'"; Reply = '4' } # Non-breaking - ) - } - - Expect = @{ - # dependency accepted as patch → 0.2.0 → 0.2.1. dependent cascade bullet-only at 0.1.1. - Released = @( - @{ Package = 'dependent'; To = '0.1.1' } - @{ Package = 'dependency'; To = '0.2.1' } - ) - PromptsRaised = @( - "Choose option for 'dependency'" - "Choose option for 'dependency'" - "Choose option for 'dependency'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S09-ignore-then-cascade.scenario.psd1 b/scripts/tests/Pester/scenarios/S09-ignore-then-cascade.scenario.psd1 deleted file mode 100644 index a9583df42..000000000 --- a/scripts/tests/Pester/scenarios/S09-ignore-then-cascade.scenario.psd1 +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S09-ignore-then-cascade' - Description = 'User declines b, then accepts c. Releasing c cascade-releases b into the release set at a non-breaking level. Because decisions are final, the planner silently accepts the cascade-applied level for b without re-prompting — the user already expressed their preference not to elevate. The cascade reason for b is surfaced in the final Show-ReleasePlan output for transparency. Confirms the simplified semantics: each package is prompted at most once.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @( - @{ Op = 'ModifySource'; Package = 'a' } - @{ Op = 'ModifySource'; Package = 'b' } - @{ Op = 'ModifySource'; Package = 'c' } - @{ Op = 'AddCommit'; Message = 'dependency edits' } - ) - - Run = @{ - Packages = @('a@patch') - Answers = @( - # Iter 0 of the scan: ignore b. - @{ Match = "Choose option for 'b'"; Reply = '2' } # No material changes - # Iter 1: accept c via option 4 (non-breaking). Option 5 (patch) is hidden - # on 0.x.y because it would produce the same numeric increment. - # c's cascade then pulls b into the release set at non-breaking - # (0.2.0 → 0.2.1). Because b was previously declined, the planner - # silently accepts the cascade-applied level and does NOT re-prompt. - @{ Match = "Choose option for 'c'"; Reply = '4' } # Non-breaking - ) - } - - Expect = @{ - # a patch (0.1.0 → 0.1.1). - # c accepted as patch (0.3.0 → 0.3.1). - # b cascade-released from c (0.2.0 → 0.2.1) despite being previously declined. - # a cascade from c bullet-only (0.1.1 already >= required). - Released = @( - @{ Package = 'a'; To = '0.1.1' } - @{ Package = 'c'; To = '0.3.1' } - @{ Package = 'b'; To = '0.2.1' } - ) - PromptsRaised = @( - "Choose option for 'b'" - "Choose option for 'c'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S10-stable-version-patch-distinct.scenario.psd1 b/scripts/tests/Pester/scenarios/S10-stable-version-patch-distinct.scenario.psd1 deleted file mode 100644 index 58ac6376b..000000000 --- a/scripts/tests/Pester/scenarios/S10-stable-version-patch-distinct.scenario.psd1 +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S10-stable-version-patch-distinct' - Description = 'Stable (>=1.x.y) workspace: the menu offers all 5 options because non-breaking (option 4) and patch (option 5) produce distinct numeric outcomes. User picks option 5 on dependency; verifies dependency ends at 1.2.4 (a patch change), proving the patch path is reachable end-to-end and is NOT a synonym for option 4 (which would yield 1.3.0). Counter-balances the 0.x-only synthetic-workspace presets, which collapse 4 and 5 into the same numeric increment.' - - # Inline spec: every built-in preset uses 0.x versions, so we hand-roll a - # stable two-package topology here to exercise the >=1.x.y branch of - # Get-NextVersion and the [1-5] menu range. - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'dependent'; Version = '1.0.0'; Deps = @(@{ Name = 'dependency' }) } - @{ Name = 'dependency'; Version = '1.2.3' } - ) - } - } - - History = @( - @{ Op = 'ModifySource'; Package = 'dependent' } - @{ Op = 'ModifySource'; Package = 'dependency' } - @{ Op = 'AddCommit'; Message = 'dependency edits' } - ) - - Run = @{ - Packages = @('dependent@patch') - Answers = @( - # On a stable >=1.x.y package the menu offers [1-5]; '5' selects the - # patch path (Action='patch'), which is numerically distinct from - # option 4 ('non-breaking' would produce 1.3.0). - @{ Match = "Choose option for 'dependency'"; Reply = '5' } # Patch - ) - } - - Expect = @{ - # dependent: 1.0.0 -> 1.0.1 (the explicit -Change Patch on the user-named release). - # dependency : 1.2.3 -> 1.2.4 (patch chosen via option 5 — distinct from option 4 which - # would have given 1.3.0). The [1-5] suffix in PromptsRaised pins the menu range too. - Released = @( - @{ Package = 'dependent'; To = '1.0.1' } - @{ Package = 'dependency'; To = '1.2.4' } - ) - PromptsRaised = @( - "Choose option for 'dependency' [1-5]" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S11-stable-version-minor-distinct.scenario.psd1 b/scripts/tests/Pester/scenarios/S11-stable-version-minor-distinct.scenario.psd1 deleted file mode 100644 index 409815589..000000000 --- a/scripts/tests/Pester/scenarios/S11-stable-version-minor-distinct.scenario.psd1 +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S11-stable-version-minor-distinct' - Description = 'Companion to S10: same stable workspace, but the user picks option 4 (non-breaking) on dependency. Verifies dependency ends at 1.3.0 (a non-breaking change), confirming that on >=1.x.y packages options 4 and 5 resolve to genuinely different on-disk versions.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'dependent'; Version = '1.0.0'; Deps = @(@{ Name = 'dependency' }) } - @{ Name = 'dependency'; Version = '1.2.3' } - ) - } - } - - History = @( - @{ Op = 'ModifySource'; Package = 'dependent' } - @{ Op = 'ModifySource'; Package = 'dependency' } - @{ Op = 'AddCommit'; Message = 'dependency edits' } - ) - - Run = @{ - Packages = @('dependent@patch') - # dependent re-exports dependency's public types; when dependency lands a - # non-breaking change, dependent's own API gains those additions, so - # cargo-semver-checks classifies dependent as non-breaking (→ 1.1.0). - SemverVerdicts = @{ dependent = 'non-breaking' } - Answers = @( - # On a stable >=1.x.y package the menu offers [1-5]; '4' selects the - # minor (non-breaking) path, distinct from the patch path of option 5. - @{ Match = "Choose option for 'dependency'"; Reply = '4' } # Non-breaking - ) - } - - Expect = @{ - # dependent: 1.0.0 -> 1.1.0. The user requested -Change Patch, but the - # post-release scan accepts dependency as a *non-breaking* change - # (1.2.3 -> 1.3.0). The cascade then escalates dependent to a - # non-breaking change too — on stable >=1.x.y, a non-breaking change - # in an dependency propagates as non-breaking in dependents (see - # Test-IsBreakingChange + the exposing-cascade logic in - # Invoke-ReleaseFlow). - # dependency: 1.2.3 -> 1.3.0 (non-breaking; option 5 would have given 1.2.4). - Released = @( - @{ Package = 'dependent'; To = '1.1.0' } - @{ Package = 'dependency'; To = '1.3.0' } - ) - PromptsRaised = @( - "Choose option for 'dependency' [1-5]" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S12-multi-package-single-invocation.scenario.psd1 b/scripts/tests/Pester/scenarios/S12-multi-package-single-invocation.scenario.psd1 deleted file mode 100644 index 42da197aa..000000000 --- a/scripts/tests/Pester/scenarios/S12-multi-package-single-invocation.scenario.psd1 +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S12-multi-package-single-invocation' - Description = 'Bundled-input core capability: one invocation releases two packages with independent change types. No dependency between the two packages, so no cascade interaction. Validates that Parse-ReleaseTokens and Resolve-ReleaseSet handle multi-token input and that Invoke-ResolvedRelease processes both in topo order without interfering.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'alpha'; Version = '1.2.3' } - @{ Name = 'beta'; Version = '0.4.5' } - ) - } - } - - History = @() - - Run = @{ - # Two independent packages, two distinct change types in one invocation. - Packages = @('alpha@nonbreaking', 'beta@breaking') - Answers = @() - } - - Expect = @{ - # alpha: 1.2.3 -> 1.3.0 (non-breaking on stable). - # beta: 0.4.5 -> 0.5.0 (breaking on 0.x; 0.x breaking is minor numerically). - Released = @( - @{ Package = 'alpha'; To = '1.3.0' } - @{ Package = 'beta'; To = '0.5.0' } - ) - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S13-pin-with-cascade-satisfied.scenario.psd1 b/scripts/tests/Pester/scenarios/S13-pin-with-cascade-satisfied.scenario.psd1 deleted file mode 100644 index e944d861a..000000000 --- a/scripts/tests/Pester/scenarios/S13-pin-with-cascade-satisfied.scenario.psd1 +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S13-pin-with-cascade-satisfied' - Description = 'Bundled-input feature: user explicitly pins a target version for one package, and a cascade from another user-source package strengthens the change-type tag but the pin still numerically satisfies the cascade requirement. Validates that Resolve-ReleaseSet keeps the pinned version verbatim (does not bump it to the cascade-required minimum) while recording the stronger requirement in EffectiveChangeType; dependent exposure propagation follows the actual version transition instead of that tag.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'dependent'; Version = '1.0.0'; Deps = @(@{ Name = 'target' }) } - @{ Name = 'target'; Version = '1.0.0' } - ) - } - } - - History = @() - - Run = @{ - # target releases as breaking → 2.0.0. Cascade requires dependent at >=2.0.0 - # because dependent exposes target. User pins dependent at 5.0.0 which - # satisfies the cascade requirement, so the pin wins. - Packages = @('target@breaking', 'dependent@5.0.0') - SemverVerdicts = @{ dependent = 'breaking' } - Answers = @() - } - - Expect = @{ - Released = @( - @{ Package = 'target'; To = '2.0.0' } - @{ Package = 'dependent'; To = '5.0.0' } - ) - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S14-pin-with-cascade-conflict.scenario.psd1 b/scripts/tests/Pester/scenarios/S14-pin-with-cascade-conflict.scenario.psd1 deleted file mode 100644 index 36cb70574..000000000 --- a/scripts/tests/Pester/scenarios/S14-pin-with-cascade-conflict.scenario.psd1 +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S14-pin-with-cascade-conflict' - Description = 'Bundled-input safety contract: user pins a target version that the cascade analysis determines is numerically too low. Resolve-ReleaseSet throws a clear error directing the user to revise the pin or use a change-type keyword. This is the design that gives explicit pins a strong guarantee — when used, the pin is honoured verbatim; when impossible, the script refuses to proceed silently.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'dependent'; Version = '1.0.0'; Deps = @(@{ Name = 'target' }) } - @{ Name = 'target'; Version = '1.0.0' } - ) - } - } - - History = @() - - Run = @{ - # target releases as breaking → 2.0.0. Cascade requires dependent at >=2.0.0. - # User pinned dependent at 1.0.1 which is below the cascade requirement → - # Resolve-ReleaseSet must throw. - Packages = @('target@breaking', 'dependent@1.0.1') - SemverVerdicts = @{ dependent = 'breaking' } - Answers = @() - } - - Expect = @{ - # No releases produced; the run terminates with an exception before - # any on-disk Cargo.toml is rewritten. - Throws = $true - ThrowsMatches = "Cannot release 'dependent' as v1.0.1" - Released = @() - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S15-auto-upgrade-of-user-source.scenario.psd1 b/scripts/tests/Pester/scenarios/S15-auto-upgrade-of-user-source.scenario.psd1 deleted file mode 100644 index dc5083afa..000000000 --- a/scripts/tests/Pester/scenarios/S15-auto-upgrade-of-user-source.scenario.psd1 +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S15-auto-upgrade-of-user-source' - Description = 'Bundled-input cascade behaviour: user requests a weak change type for a package, but a cascade from another user-source package mandates a stronger change type. Resolve-ReleaseSet auto-upgrades the user-source entry and Show-ReleasePlan flags the upgrade with the ''auto-upgraded by cascade'' tag so the user has visibility into what happened. No prompt is raised — the upgrade is silent (no user judgement needed; cascade rules are deterministic).' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ - Name = 'dependent' - Version = '1.0.0' - Deps = @(@{ Name = 'target' }) - AllowedExternalTypes = @('target::*') - } - @{ Name = 'target'; Version = '1.0.0' } - ) - } - } - - History = @() - - Run = @{ - # User wants dependent at patch (1.0.0 → 1.0.1), but target releases - # as breaking (1.0.0 → 2.0.0) and dependent exposes target → cascade - # required-level is breaking. Resolve-ReleaseSet auto-upgrades - # dependent's EffectiveChangeType to breaking and EffectiveTargetVersion - # to 2.0.0. - Packages = @('target@breaking', 'dependent@patch') - Answers = @() - } - - Expect = @{ - Released = @( - @{ Package = 'target'; To = '2.0.0' } - @{ Package = 'dependent'; To = '2.0.0' } - ) - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S16-stable-cascade-elevation.scenario.psd1 b/scripts/tests/Pester/scenarios/S16-stable-cascade-elevation.scenario.psd1 deleted file mode 100644 index 5067f6781..000000000 --- a/scripts/tests/Pester/scenarios/S16-stable-cascade-elevation.scenario.psd1 +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S16-stable-cascade-elevation' - Description = 'Stable 1.x topology exercising Invariant B end-to-end. Three-package chain ''top -> middle -> bottom'' where ''middle'' has pre-existing source modifications. User releases ''bottom'' as patch. Cascade pulls ''middle'' (1.0.0 -> 1.0.1) and ''top'' (1.0.0 -> 1.0.1) as patch changes. Because ''middle'' is ALSO modified and its cascade-applied change type is below breaking, the post-release scan surfaces ''middle'' (reached via ''top.Deps = [middle]''). User picks option 4 (non-breaking) → ''middle'' escalates from cascade-applied 1.0.1 to 1.1.0, and the re-cascade lifts ''top'' to 1.1.0 too (exposing-dependent minor cascade). - -This is the stable-version companion to S06 (the same flow on 0.x.y). Validates that Invariant B elevation works correctly through Invoke-ReleaseFlow''s ``$isPendingPrimary`` branch on >=1.x packages.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'top'; Version = '1.0.0'; Deps = @(@{ Name = 'middle' }) } - @{ Name = 'middle'; Version = '1.0.0'; Deps = @(@{ Name = 'bottom' }) } - @{ Name = 'bottom'; Version = '1.0.0' } - ) - } - } - - History = @( - @{ Op = 'ModifySource'; Package = 'middle' } - @{ Op = 'AddCommit'; Message = 'middle source edits' } - ) - - Run = @{ - Packages = @('bottom@patch') - # Simulated cargo-semver-checks verdicts. 'top' re-exports 'middle' and - # its own public API gains 'middle's non-breaking additions, so the tool - # classifies top as non-breaking (→ 1.1.0). 'middle' itself is analysed - # as patch by the tool but the user elevates it via Invariant B review. - SemverVerdicts = @{ top = 'non-breaking' } - Answers = @( - # Invariant B: middle was cascade-pulled with a patch change - # AND has pre-existing modifications. User elevates to - # non-breaking (option 4 = minor on 1.x). - @{ Match = "Choose option for 'middle'"; Reply = '4' } # Non-breaking - ) - } - - Expect = @{ - # bottom: 1.0.0 -> 1.0.1 (user-requested patch). - # middle: cascade-released to 1.0.1, then escalated to 1.1.0 by the - # post-release scan accepting it as non-breaking. - # top: cascade-released to 1.1.0 because cargo-semver-checks - # classifies top's own public API change (from re-exporting - # middle's non-breaking additions) as non-breaking. - Released = @( - @{ Package = 'bottom'; To = '1.0.1' } - @{ Package = 'middle'; To = '1.1.0' } - @{ Package = 'top'; To = '1.1.0' } - ) - PromptsRaised = @( - "Choose option for 'middle' [1-5]" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S17-explicit-version-pin.scenario.psd1 b/scripts/tests/Pester/scenarios/S17-explicit-version-pin.scenario.psd1 deleted file mode 100644 index 3d6c94ab7..000000000 --- a/scripts/tests/Pester/scenarios/S17-explicit-version-pin.scenario.psd1 +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S17-explicit-version-pin' - Description = 'Bundled-input explicit-pin contract: user supplies an explicit ''1.0.0'' semver pin on a 0.x.y package. The planner accepts the pin because the package is currently below 1.0.0 (pin must be strictly greater than the current version). 1.0.0 has no special handling — it is treated like any other explicit pin.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'graduating'; Version = '0.7.2' } - ) - } - } - - History = @() - - Run = @{ - Packages = @('graduating@1.0.0') - Answers = @() - } - - Expect = @{ - Released = @( - @{ Package = 'graduating'; To = '1.0.0' } - ) - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S18-explicit-pin-rejected-when-not-greater.scenario.psd1 b/scripts/tests/Pester/scenarios/S18-explicit-pin-rejected-when-not-greater.scenario.psd1 deleted file mode 100644 index 891b991ec..000000000 --- a/scripts/tests/Pester/scenarios/S18-explicit-pin-rejected-when-not-greater.scenario.psd1 +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S18-explicit-pin-rejected-when-not-greater' - Description = 'Bundled-input explicit-pin safety contract: user supplies an explicit ''1.0.0'' semver pin on a package already at >= 1.0.0. The planner throws because explicit version pins must be strictly greater than the current on-disk version. (1.0.0 has no special meaning — this is the same error any not-strictly-greater pin would raise.)' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'already-stable'; Version = '1.4.2' } - ) - } - } - - History = @() - - Run = @{ - Packages = @('already-stable@1.0.0') - Answers = @() - } - - Expect = @{ - Throws = $true - ThrowsMatches = 'already at v1.4.2' - Released = @() - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S19-changed-ignore-everything.scenario.psd1 b/scripts/tests/Pester/scenarios/S19-changed-ignore-everything.scenario.psd1 deleted file mode 100644 index 200f5f9a9..000000000 --- a/scripts/tests/Pester/scenarios/S19-changed-ignore-everything.scenario.psd1 +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S19-changed-ignore-everything' - Description = 'Linear3 (a -> b -> c) with all three packages modified. Run in -Mode changed and ignore every prompt; expect no releases and no errors. Validates the early-exit path when the user declines every surfaced finding.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @( - @{ Op = 'ModifySource'; Package = 'a' } - @{ Op = 'ModifySource'; Package = 'b' } - @{ Op = 'ModifySource'; Package = 'c' } - @{ Op = 'AddCommit'; Message = 'edits to all three packages' } - ) - - Run = @{ - # Mode='changed' invokes Invoke-ReleasePackagesMain -Mode 'changed' - # (no -Packages list). The review loop seeds BFS roots from every - # changed package, so the user is walked through b, c, a in that - # order — b and c come first as BFS-recorded dependencies of a; - # a comes last as a Phase-B stub (no in-release-set dependents). - Mode = 'changed' - Answers = @( - @{ Match = "Choose option for 'b'"; Reply = '2' } # No material changes - @{ Match = "Choose option for 'c'"; Reply = '2' } # No material changes - @{ Match = "Choose option for 'a'"; Reply = '2' } # No material changes - ) - } - - Expect = @{ - # No releases — every package was ignored. - PromptsRaised = @( - "Choose option for 'b'" - "Choose option for 'c'" - "Choose option for 'a'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S20-changed-accept-with-cascade.scenario.psd1 b/scripts/tests/Pester/scenarios/S20-changed-accept-with-cascade.scenario.psd1 deleted file mode 100644 index f66e22872..000000000 --- a/scripts/tests/Pester/scenarios/S20-changed-accept-with-cascade.scenario.psd1 +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S20-changed-accept-with-cascade' - Description = 'Linear3 (a -> b -> c) with all three packages modified. Run in -Mode changed: user accepts b as non-breaking (which cascades a as non-breaking), then ignores c and the cascade-elevated a. Final release set = {b, a}.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @( - @{ Op = 'ModifySource'; Package = 'a' } - @{ Op = 'ModifySource'; Package = 'b' } - @{ Op = 'ModifySource'; Package = 'c' } - @{ Op = 'AddCommit'; Message = 'edits to all three packages' } - ) - - Run = @{ - Mode = 'changed' - Answers = @( - # Iter 1: queue = [b, c, a]. Accept b as non-breaking (option 4). - # In 0.x semver this is "patch-style": 0.2.0 -> 0.2.1. We pick - # non-breaking (not breaking) so the cascade onto 'a' lands as - # non-breaking too, which keeps 'a' eligible for the elevation- - # review prompt below. (A breaking cascade would mark 'a' as - # Source='cascade' with EffectiveChangeType='breaking' and the - # surfacing predicate would skip it.) - @{ Match = "Choose option for 'b'"; Reply = '4' } # Non-breaking - # Iter 2: after accepting b@nonbreaking, Resolve-ReleaseSet pulls - # 'a' in as a cascade non-breaking. Findings now surface c (still - # modified+unreleased) and a (Source='cascade' + non-breaking, so - # eligible for elevation review). User ignores c. - @{ Match = "Choose option for 'c'"; Reply = '2' } # No material changes - # User leaves a at its cascade-applied non-breaking level (ignore - # = "keep cascade-applied level"; recorded into $reviewedCascadeAsIs - # so it does not re-surface on the next iteration). - @{ Match = "Choose option for 'a'"; Reply = '2' } # No material changes - ) - } - - Expect = @{ - # 0.x semver: b at 0.2.0 non-breaking -> 0.2.1; a at 0.1.0 cascade - # non-breaking -> 0.1.1. c stays unreleased. - Released = @( - @{ Package = 'b'; To = '0.2.1' } - @{ Package = 'a'; To = '0.1.1' } - ) - PromptsRaised = @( - "Choose option for 'b'" - "Choose option for 'c'" - "Choose option for 'a'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S21-changed-no-modifications.scenario.psd1 b/scripts/tests/Pester/scenarios/S21-changed-no-modifications.scenario.psd1 deleted file mode 100644 index e2d776b4d..000000000 --- a/scripts/tests/Pester/scenarios/S21-changed-no-modifications.scenario.psd1 +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S21-changed-no-modifications' - Description = 'Linear3 with NO modifications. Run in -Mode changed; expect the entry point to print "no changed packages detected" and exit cleanly without invoking the review loop or releasing anything.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @() # no modifications - - Run = @{ - Mode = 'changed' - Answers = @() # no prompts expected — early exit before review loop - } - - Expect = @{ - # No releases, no prompts raised. - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S22-all-no-modifications-ignore-all.scenario.psd1 b/scripts/tests/Pester/scenarios/S22-all-no-modifications-ignore-all.scenario.psd1 deleted file mode 100644 index f1aa34859..000000000 --- a/scripts/tests/Pester/scenarios/S22-all-no-modifications-ignore-all.scenario.psd1 +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S22-all-no-modifications-ignore-all' - Description = 'Linear3 (a -> b -> c) with NO modifications. Run in -Mode all: the planner surfaces every publishable package for review (despite the empty change set) and the user ignores each in turn. Expect no releases and a prompt per package. Validates that -All bypasses the change-detection filter that -Changed enforces.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @() # no modifications - - Run = @{ - # Mode='all' invokes Invoke-ReleasePackagesMain -Mode 'all'. The - # snapshot is synthesised in the entry point so every published - # package is surfaced as a BFS root. Initial walk order matches - # S19/S20 because the BFS still records chains via 'a' first - # (sorted root order: a, b, c → a's BFS visits b then c, c's BFS - # is empty, and Phase-B sweep adds a as a stub finding). - Mode = 'all' - Answers = @( - @{ Match = "Choose option for 'b'"; Reply = '2' } # No material changes - @{ Match = "Choose option for 'c'"; Reply = '2' } # No material changes - @{ Match = "Choose option for 'a'"; Reply = '2' } # No material changes - ) - } - - Expect = @{ - # No releases — every package was ignored. The point of this scenario - # is to prove -All surfaces unchanged packages at all (something - # -Changed would skip), not to release anything. - PromptsRaised = @( - "Choose option for 'b'" - "Choose option for 'c'" - "Choose option for 'a'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S23-all-force-release-unchanged.scenario.psd1 b/scripts/tests/Pester/scenarios/S23-all-force-release-unchanged.scenario.psd1 deleted file mode 100644 index 21ad59572..000000000 --- a/scripts/tests/Pester/scenarios/S23-all-force-release-unchanged.scenario.psd1 +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S23-all-force-release-unchanged' - Description = 'Linear3 (a -> b -> c) with NO modifications. Run in -Mode all: user ignores b, then accepts c as breaking. The cascade-toward-dependents walk pulls b and a in as cascade-breaking (mirroring c''s change type), which silences the elevation prompt for both (Invariant B: cascade entries that already match the strongest change type are not re-surfaced). Net result: c, b, and a all released at breaking version bumps. Validates that -All can release packages with no on-disk changes, and that cascade-breaking suppresses follow-up prompts.' - - Workspace = @{ Preset = 'Linear3' } # a -> b -> c - - History = @() # no modifications — -All surfaces packages regardless - - Run = @{ - Mode = 'all' - # a and b re-export c's public types, so when c lands a breaking change - # their own APIs break too — cargo-semver-checks classifies both as - # breaking. This is what makes the cascade mirror c's breaking onto - # them (and, being at the breaking ceiling, suppresses their prompts). - SemverVerdicts = @{ a = 'breaking'; b = 'breaking' } - Answers = @( - # Initial queue (no tokens, all roots): b, c, a (see S22 comment - # for the BFS-root expansion that produces this order). - # - # We have no decision for b yet; ignore so c is reached as the - # breaking trigger. 'b' is recorded in $declined for this run - # (b is not InReleaseSet at this point: no tokens, no cascade). - @{ Match = "Choose option for 'b'"; Reply = '2' } # No material changes - # Accept c as breaking (option 3). c: 0.3.0 -> 0.4.0. The - # cascade-toward-dependents walk mirrors c's change type onto b - # and onto a (transitively), so both arrive in the release set - # as cascade-breaking. The surfacing predicate skips cascade - # entries already at the "breaking" ceiling (Invariant B), so - # neither b nor a is re-prompted, AND a is dropped from the - # initial queue (it was a Phase-B stub before c's acceptance; - # the next iteration sees it as cascade-breaking and filters it - # out). - @{ Match = "Choose option for 'c'"; Reply = '3' } # Breaking - ) - } - - Expect = @{ - # 0.x cargo rules: breaking 0.x.y -> 0.(x+1).0. - # c: 0.3.0 -> 0.4.0 (user-accepted breaking). - # b: 0.2.0 -> 0.3.0 (cascade breaking from c). - # a: 0.1.0 -> 0.2.0 (cascade breaking from b). - Released = @( - @{ Package = 'c'; To = '0.4.0' } - @{ Package = 'b'; To = '0.3.0' } - @{ Package = 'a'; To = '0.2.0' } - ) - # Only b and c are prompted. a never reaches the prompt: when c is - # accepted in iter 2, the re-resolve marks a as cascade-breaking, - # which the surfacing predicate filters out before the prompt would - # fire. This is the same elevation-suppression logic that protects - # users from being asked to re-confirm a release already at the - # ceiling change type. - PromptsRaised = @( - "Choose option for 'b'" - "Choose option for 'c'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S24-pin-with-cascade-conflict-force.scenario.psd1 b/scripts/tests/Pester/scenarios/S24-pin-with-cascade-conflict-force.scenario.psd1 deleted file mode 100644 index da16c3419..000000000 --- a/scripts/tests/Pester/scenarios/S24-pin-with-cascade-conflict-force.scenario.psd1 +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S24-pin-with-cascade-conflict-force' - Description = '-Force overrides the pin-vs-cascade rejection: same workspace and pin as S14, but the user passes -Force so the explicit pin (dependent@1.0.1) is honoured verbatim even though cascade requires >=2.0.0. The package emits a warning (visible to a maintainer running interactively), Resolve-ReleaseSet tags the entry PinHonoredAgainstCascade, and Invoke-ResolvedRelease writes the pinned version on disk. The mirror-image scenario S14 covers the rejection path; this one covers the override path. End-to-end coverage proves the -Force switch is plumbed all the way from Invoke-ReleasePackagesMain through Invoke-PlanReview into Resolve-ReleaseSet (unit coverage of the resolver alone cannot demonstrate the parameter wiring).' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'dependent'; Version = '1.0.0'; Deps = @(@{ Name = 'target' }) } - @{ Name = 'target'; Version = '1.0.0' } - ) - } - } - - History = @() - - Run = @{ - Packages = @('target@breaking', 'dependent@1.0.1') - SemverVerdicts = @{ dependent = 'breaking' } - Force = $true - Answers = @() - } - - Expect = @{ - # No exception this time: -Force converts the rejection into a warning. - Throws = $false - Released = @( - @{ Package = 'target'; To = '2.0.0' } - # Cascade required >=2.0.0, but the user's pin (1.0.1) is honoured - # verbatim under -Force. The on-disk version is the pin. - @{ Package = 'dependent'; To = '1.0.1' } - ) - # The workspace has no modifications, so no per-package elevation - # prompts fire; -Force only converts the resolver's throw into a - # warning (Write-Warning, not Read-Host). - PromptsRaised = @() - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S25-proc-macro-user-review.scenario.psd1 b/scripts/tests/Pester/scenarios/S25-proc-macro-user-review.scenario.psd1 deleted file mode 100644 index eb0f3f3e7..000000000 --- a/scripts/tests/Pester/scenarios/S25-proc-macro-user-review.scenario.psd1 +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S25-proc-macro-user-review' - Description = 'A breaking user-selected proc macro triggers manual review of its direct published consumer. Keeping that consumer at patch stops review propagation, while the next-level dependent retains normal cascade classification.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'downstream'; Version = '1.0.0'; Deps = @(@{ Name = 'consumer' }) } - @{ Name = 'consumer'; Version = '1.0.0'; Deps = @(@{ Name = 'macros' }) } - @{ Name = 'macros'; Version = '1.0.0'; ProcMacro = $true } - ) - } - } - - History = @() - - Run = @{ - Packages = @('macros@patch') - Answers = @( - @{ Match = "Choose option for 'macros'"; Reply = '1' } - @{ Match = "Choose option for 'macros'"; Reply = '3' } - @{ Match = "Choose option for 'consumer'"; Reply = '2' } - ) - } - - Expect = @{ - Released = @( - @{ Package = 'macros'; To = '2.0.0' } - @{ Package = 'consumer'; To = '1.0.1' } - @{ Package = 'downstream'; To = '1.0.1' } - ) - PromptsRaised = @( - "Choose option for 'macros'" - "Choose option for 'macros'" - "Choose option for 'consumer'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S26-proc-macro-cascade-review.scenario.psd1 b/scripts/tests/Pester/scenarios/S26-proc-macro-cascade-review.scenario.psd1 deleted file mode 100644 index 9642c60c4..000000000 --- a/scripts/tests/Pester/scenarios/S26-proc-macro-cascade-review.scenario.psd1 +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S26-proc-macro-cascade-review' - Description = 'A proc-macro-only dependent pulled into the release set by an implementation-crate release is manually reviewed even when its own package folder is unchanged. Selecting a non-breaking release replaces the mechanical patch floor and does not trigger downstream manual review.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'consumer'; Version = '1.0.0'; Deps = @(@{ Name = 'macros' }) } - @{ Name = 'macros'; Version = '1.0.0'; ProcMacro = $true; Deps = @(@{ Name = 'implementation' }) } - @{ Name = 'implementation'; Version = '1.0.0' } - ) - } - } - - History = @() - - Run = @{ - Packages = @('implementation@patch') - Answers = @( - @{ Match = "Choose option for 'macros'"; Reply = '4' } - ) - } - - Expect = @{ - Released = @( - @{ Package = 'implementation'; To = '1.0.1' } - @{ Package = 'macros'; To = '1.1.0' } - @{ Package = 'consumer'; To = '1.0.1' } - ) - PromptsRaised = @( - "Choose option for 'macros'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S27-proc-macro-recursive-review.scenario.psd1 b/scripts/tests/Pester/scenarios/S27-proc-macro-recursive-review.scenario.psd1 deleted file mode 100644 index 756e76a1b..000000000 --- a/scripts/tests/Pester/scenarios/S27-proc-macro-recursive-review.scenario.psd1 +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S27-proc-macro-recursive-review' - Description = 'Manual review advances one published dependency edge at a time: a breaking proc macro surfaces its facade, and a breaking facade then surfaces its direct consumer.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ - Name = 'app' - Version = '1.0.0' - Deps = @(@{ Name = 'facade' }) - AllowedExternalTypes = @() - } - @{ Name = 'facade'; Version = '1.0.0'; Deps = @(@{ Name = 'macros' }) } - @{ Name = 'macros'; Version = '1.0.0'; ProcMacro = $true } - ) - } - } - - History = @() - - Run = @{ - Packages = @('macros@patch') - Answers = @( - @{ Match = "Choose option for 'macros'"; Reply = '3' } - @{ Match = "Choose option for 'facade'"; Reply = '3' } - @{ Match = "Choose option for 'app'"; Reply = '2' } - ) - } - - Expect = @{ - Released = @( - @{ Package = 'macros'; To = '2.0.0' } - @{ Package = 'facade'; To = '2.0.0' } - @{ Package = 'app'; To = '1.0.1' } - ) - PromptsRaised = @( - "Choose option for 'macros'" - "Choose option for 'facade'" - "Choose option for 'app'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/S28-proc-macro-no-material-then-cascade.scenario.psd1 b/scripts/tests/Pester/scenarios/S28-proc-macro-no-material-then-cascade.scenario.psd1 deleted file mode 100644 index e5db1a9b5..000000000 --- a/scripts/tests/Pester/scenarios/S28-proc-macro-no-material-then-cascade.scenario.psd1 +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -@{ - Name = 'S28-proc-macro-no-material-then-cascade' - Description = 'Choosing no material changes completes proc-macro review. If a later decision pulls that proc macro into the release plan, it gets the patch floor without a second prompt.' - - Workspace = @{ - Spec = @{ - Packages = @( - @{ Name = 'seed'; Version = '1.0.0'; Deps = @(@{ Name = 'macros' }) } - @{ Name = 'macros'; Version = '1.0.0'; ProcMacro = $true; Deps = @(@{ Name = 'implementation' }) } - @{ Name = 'implementation'; Version = '1.0.0' } - ) - } - } - - History = @( - @{ Op = 'ModifySource'; Package = 'seed' } - @{ Op = 'ModifySource'; Package = 'macros' } - @{ Op = 'ModifySource'; Package = 'implementation' } - @{ Op = 'AddCommit'; Message = 'package edits' } - ) - - Run = @{ - Packages = @('seed@patch') - Answers = @( - @{ Match = "Choose option for 'macros'"; Reply = '2' } - @{ Match = "Choose option for 'implementation'"; Reply = '5' } - ) - } - - Expect = @{ - Released = @( - @{ Package = 'seed'; To = '1.0.1' } - @{ Package = 'macros'; To = '1.0.1' } - @{ Package = 'implementation'; To = '1.0.1' } - ) - PromptsRaised = @( - "Choose option for 'macros'" - "Choose option for 'implementation'" - ) - UnconsumedAnswers = @() - } -} diff --git a/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 b/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 deleted file mode 100644 index cf15f5aab..000000000 --- a/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -<# -.SYNOPSIS - Phase 6 — end-to-end scenario tests. - -.DESCRIPTION - Loads every *.scenario.psd1 under scripts/tests/Pester/scenarios/ and - runs each one via Invoke-Scenario. Each scenario builds a synthetic - workspace, replays history, invokes Invoke-ReleasePackagesMain in-process - (with mocked Read-Host / Invoke-WorkspaceCheck / Test-InteractiveSession), - then asserts on the resulting release records and raised prompts. -#> - -BeforeAll { - . (Join-Path $PSScriptRoot '..\_common\TestHelpers.ps1') - . (Join-Path $PSScriptRoot '..\_common\New-SyntheticWorkspace.ps1') - . (Join-Path $PSScriptRoot '..\_common\Invoke-Scenario.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') -} - -# Discover all scenarios at top-level (Discovery phase) so the cached list is -# available to both `BeforeAll` (Run phase) and the `It -ForEach` parameter -# (also evaluated at Discovery time). Computing it once here keeps the source -# of truth in a single place. -$script:ScenarioFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot '..\scenarios') -Filter '*.scenario.psd1' | - ForEach-Object { @{ File = $_.FullName; Name = $_.BaseName -replace '\.scenario$', '' } } - -Describe 'End-to-end release scenarios' { - - # Per-It mocks rather than BeforeAll mocks: Pester 5 requires Mocks inside - # the It or in a BeforeEach to take effect for the It's invocation. - BeforeEach { - # cargo check is too expensive (and irrelevant) for scenarios; mock it to no-op. - Mock -CommandName Invoke-WorkspaceCheck -MockWith { } -Verifiable:$false - - # Force interactive mode so the prompt flow is exercised even when - # tests run under CI / pwsh non-tty. - Mock -CommandName Test-InteractiveSession -MockWith { $true } -Verifiable:$false - - # The entry point's pre-flight asserts git and cargo-semver-checks are on - # PATH. Scenarios mock the classifier (Get-CrateRequiredChangeType) so the - # real cargo-semver-checks binary is never invoked and need not be - # installed on the runner — satisfy the presence check via mock. - Mock -CommandName Test-CommandExists -MockWith { $true } -Verifiable:$false - - # Suppress real editor launches when scenarios exercise the View Diff path. - Mock -CommandName Open-PathWithPreferredEditor -MockWith { } -Verifiable:$false - - # Route Read-Host through the scenario answer queue. - Mock -CommandName Read-Host -MockWith { - param([string]$Prompt) - return Resolve-ScenarioPromptReply -Prompt $Prompt - } -Verifiable:$false - - # Replace real cargo-semver-checks with the scenario's simulated verdict - # map (folder -> change type), so cascade/self-floor classification is - # deterministic and offline. Unmapped folders default to 'none'. - Mock -CommandName Get-CrateRequiredChangeType -MockWith { - param([string]$Folder, [string]$CargoName, [string]$RepoRoot) - if ($script:ScenarioSemverVerdicts -and $script:ScenarioSemverVerdicts.ContainsKey($Folder)) { - return $script:ScenarioSemverVerdicts[$Folder] - } - return 'none' - } -Verifiable:$false - } - - It '' -ForEach $script:ScenarioFiles { - $result = Invoke-Scenario -ScenarioFile $File - $expect = $result.Scenario.Expect - - if ($expect.Throws) { - $result.Error | Should -Not -BeNullOrEmpty -Because "scenario expected an exception" - if ($expect.ThrowsMatches) { - $result.Error.Exception.Message | Should -Match ([regex]::Escape($expect.ThrowsMatches)) -Because "exception message did not contain the expected substring" - } - } elseif ($result.Error) { - throw "Scenario '$Name' threw: $($result.Error)" - } - - # --- Released packages: at least every expected entry must appear with the expected version. - # Use a $null-check (not truthiness) so a scenario that explicitly - # asserts NO releases via `Released = @()` still triggers the - # release-set bound check below — an empty array is falsy in - # PowerShell, and the truthiness form would have skipped the entire - # block and silently let unexpected releases leak through. - if ($null -ne $expect.Released) { - $diag = "PromptsRaised: [$($result.PromptsRaised -join ' | ')]; RepliesGiven: [$($result.RepliesGiven -join ' | ')]; Releases: [$(($result.Releases | ForEach-Object { "$($_.Package)=$($_.NewVersion)" }) -join ', ')]" - foreach ($exp in @($expect.Released)) { - $actual = $result.Releases | Where-Object { $_.Package -eq $exp.Package } | Select-Object -First 1 - $actual | Should -Not -BeNullOrEmpty -Because "scenario expected '$($exp.Package)' in the release set; $diag" - $actual.NewVersion | Should -Be $exp.To -Because "scenario expected '$($exp.Package)' to end at $($exp.To); $diag" - } - # Bound the release set: no extra packages beyond those expected. - $expectedNames = @($expect.Released | ForEach-Object { $_.Package }) - $actualNames = @($result.Releases | ForEach-Object { $_.Package }) - $unexpected = $actualNames | Where-Object { $expectedNames -notcontains $_ } - $unexpected | Should -BeNullOrEmpty -Because "scenario expected only [$($expectedNames -join ', ')]; got extras: [$($unexpected -join ', ')]" - } - - # --- Prompts raised (substring match, ordered). - if ($null -ne $expect.PromptsRaised) { - $expectedPrompts = @($expect.PromptsRaised) - $actualPrompts = @($result.PromptsRaised) - $actualPrompts.Count | Should -Be $expectedPrompts.Count -Because "expected $($expectedPrompts.Count) prompts; got $($actualPrompts.Count): [$($actualPrompts -join ' | ')]" - for ($i = 0; $i -lt $expectedPrompts.Count; $i++) { - $actualPrompts[$i] | Should -Match ([regex]::Escape($expectedPrompts[$i])) -Because "prompt #$i did not match" - } - } - - # --- All scripted answers must have been consumed. - if ($null -ne $expect.UnconsumedAnswers) { - $result.UnconsumedAnswers.Count | Should -Be ($expect.UnconsumedAnswers | Measure-Object).Count -Because "unconsumed answers remain: $($result.UnconsumedAnswers | ConvertTo-Json -Compress)" - } - } -} diff --git a/scripts/tests/Pester/unit/release-crate/PromptFlow.Tests.ps1 b/scripts/tests/Pester/unit/release-crate/PromptFlow.Tests.ps1 deleted file mode 100644 index e292fa900..000000000 --- a/scripts/tests/Pester/unit/release-crate/PromptFlow.Tests.ps1 +++ /dev/null @@ -1,1580 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# -# Unit tests for the per-package menu and prompt-flow helpers added by the -# release-script UX overhaul. Helpers under test live in -# scripts/lib/release-flow.ps1 and are deliberately split so the pure -# formatting layer can be asserted on without capturing host streams and the -# IO/IO-adjacent layer can be exercised with mocks. -# -# ┌─────────────────────────────────────────────────────────────────────────┐ -# │ Pester mock pitfall (DO NOT use `,@(...)` to wrap arrays in mocks) │ -# ├─────────────────────────────────────────────────────────────────────────┤ -# │ When mocking a function whose output is consumed via the pattern │ -# │ $queue = @( @(SomeFunc ...) | Where-Object { ... } ) │ -# │ (see release-flow.ps1's Invoke-PlanReview), the leading-comma │ -# │ wrapping idiom │ -# │ Mock SomeFunc -MockWith { │ -# │ ,@( item1, item2, item3 ) # <-- WRONG │ -# │ } │ -# │ does NOT do what it looks like. PowerShell emits the inner @() as a │ -# │ single object on the pipeline (the comma forces it). The outer @() of │ -# │ the consumer then collects 1 pipeline output (the inner array), so │ -# │ $queue.Count == 1 instead of N, and $queue[0] is the inner array — not │ -# │ a finding. Member-enumeration on the fused element ($queue[0].Folder) │ -# │ returns the space-joined property values ('a b c'), which often looks │ -# │ "right" enough to pass weak substring assertions but causes the loop │ -# │ to execute only one iteration instead of N. │ -# │ │ -# │ ALWAYS emit items directly: │ -# │ Mock SomeFunc -MockWith { │ -# │ [pscustomobject]@{ Folder = 'a'; ... } # <-- correct │ -# │ [pscustomobject]@{ Folder = 'b'; ... } │ -# │ [pscustomobject]@{ Folder = 'c'; ... } │ -# │ } │ -# │ The pipeline naturally streams each, the consumer's @() collects them │ -# │ into an N-element array, and $queue[0] is the first finding object. │ -# │ │ -# │ (Exception: `@(,@('a', 'b'))` for DependencyChains is legitimate — │ -# │ that builds an array-of-arrays where each element is a chain.) │ -# │ │ -# │ History: this pattern was already removed from Get-WorkspacePackages │ -# │ mocks in commit 53948dc0 after it silently capped maxIterations to 1; │ -# │ a second pass cleaned up the same idiom in │ -# │ Get-UnreleasedModifiedDependencies mocks. │ -# └─────────────────────────────────────────────────────────────────────────┘ - -BeforeAll { - . (Join-Path $PSScriptRoot '..\..\_common\TestHelpers.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') -} - -# --------------------------------------------------------------------------- -# Format-PackageMenu (pure formatter) -# --------------------------------------------------------------------------- - -Describe 'Format-PackageMenu' { - - BeforeAll { - function script:NewFinding { - param( - [string]$Folder = 'ohno', - [object[]]$Chains = @(@('a', 'ohno')), - [string]$CurrentVersion = '1.2.3', - [bool]$InReleaseSet = $false, - [string]$PlannedCurrentVersion = $null, - [string]$EffectiveChangeType = $null, - [string]$EffectiveTargetVersion = $null - ) - return [pscustomobject]@{ - Folder = $Folder - PackageName = $Folder - CurrentVersion = $CurrentVersion - InReleaseSet = $InReleaseSet - PlannedCurrentVersion = $PlannedCurrentVersion - EffectiveChangeType = $EffectiveChangeType - EffectiveTargetVersion = $EffectiveTargetVersion - ChangedFileCount = 1 - # DependencyChains stays release-set-rooted for the PR comment - # and non-interactive bail-out paths; the menu reads only - # WorkspaceDependencyChains. Populate both fields on test - # findings so other consumers still get sensible data. - DependencyChains = $Chains - WorkspaceDependencyChains = $Chains - } - } - } - - It 'includes the package name on the first content line' { - $out = Format-PackageMenu -Finding (NewFinding -Folder 'ohno') -RemainingCount 0 - $out | Should -Match 'Detected package with unreleased modifications: ohno' - } - - Context 'manual proc-macro SemVer review' { - It 'renders the same menu as an ordinary package already in the release plan' { - $ordinary = NewFinding -Folder 'macros' -CurrentVersion '1.2.3' ` - -InReleaseSet $true -PlannedCurrentVersion '1.2.3' ` - -EffectiveChangeType 'patch' -EffectiveTargetVersion '1.2.4' - - $finding = NewFinding -Folder 'macros' -CurrentVersion '1.2.3' ` - -InReleaseSet $true -PlannedCurrentVersion '1.2.3' ` - -EffectiveChangeType 'patch' -EffectiveTargetVersion '1.2.4' - $finding | Add-Member -NotePropertyName RequiresManualSemverReview -NotePropertyValue $true - $finding | Add-Member -NotePropertyName ManualSemverReviewKind -NotePropertyValue 'proc-macro' - $finding | Add-Member -NotePropertyName ManualSemverReviewSources -NotePropertyValue @() - - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - - $out | Should -Be (Format-PackageMenu -Finding $ordinary -RemainingCount 0) - $out | Should -Match 'Detected package with unreleased modifications: macros' - $out | Should -Match '2\. Keep the release level already in the plan \(patch: 1\.2\.3 -> 1\.2\.4\)' - $out | Should -Not -Match 'Manual SemVer|cargo-semver-checks|proc-macro' - } - - It 'renders the same menu for a mandatory downstream review as for an ordinary package' { - $ordinary = NewFinding -Folder 'facade' -CurrentVersion '1.2.3' ` - -InReleaseSet $true -PlannedCurrentVersion '1.2.3' ` - -EffectiveChangeType 'non-breaking' -EffectiveTargetVersion '1.3.0' - - $finding = NewFinding -Folder 'facade' -CurrentVersion '1.2.3' ` - -InReleaseSet $true -PlannedCurrentVersion '1.2.3' ` - -EffectiveChangeType 'non-breaking' -EffectiveTargetVersion '1.3.0' - $finding | Add-Member -NotePropertyName RequiresManualSemverReview -NotePropertyValue $true - $finding | Add-Member -NotePropertyName ManualSemverReviewKind -NotePropertyValue 'proc-macro-dependent' - $finding | Add-Member -NotePropertyName ManualSemverReviewSources -NotePropertyValue @('macros') - - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - - $out | Should -Be (Format-PackageMenu -Finding $ordinary -RemainingCount 0) - $out | Should -Match 'Detected package with unreleased modifications: facade' - $out | Should -Not -Match 'Manual SemVer|cargo-semver-checks|proc-macro' - } - } - - It 'shows the planned release level and target version in option 2' { - $finding = NewFinding -Folder 'anyspawn' -CurrentVersion '0.6.0' ` - -InReleaseSet $true -PlannedCurrentVersion '0.6.0' ` - -EffectiveChangeType 'patch' -EffectiveTargetVersion '0.6.1' - - $out = Format-PackageMenu -Finding $finding -RemainingCount 30 - - $out | Should -Match '2\. Keep the release level already in the plan \(patch: 0\.6\.0 -> 0\.6\.1\)' - } - - It 'keeps the legacy option 2 wording when a hand-crafted finding lacks plan details' { - $finding = [pscustomobject]@{ - Folder = 'ohno' - PackageName = 'ohno' - CurrentVersion = '1.2.3' - InReleaseSet = $true - ChangedFileCount = 1 - DependencyChains = @() - WorkspaceDependencyChains = @() - } - - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $option2 = $out -split "`r?`n" | Where-Object { $_ -match '^\s*2\. ' } | Select-Object -First 1 - - $option2.Trim() | Should -Be '2. Keep the release level already in the plan' - } - - Context 'no-changes (-All-mode) finding' { - - # When the planner surfaces a package via -All mode there may be no - # on-disk modification at all. ChangedFileCount = 0 (or missing) - # signals this case; the menu must adapt its header and the - # "View diff" label so the reviewer is not misled into expecting - # changes that aren't there. The View-diff option still occupies - # menu slot 1 for muscle-memory consistency with the changed-finding - # variant. - - function script:NewNoChangesFinding { - param([string]$Folder = 'unchanged', [string]$CurrentVersion = '1.2.3') - return [pscustomobject]@{ - Folder = $Folder - PackageName = $Folder - CurrentVersion = $CurrentVersion - ChangedFileCount = 0 - DependencyChains = @() - WorkspaceDependencyChains = @() - } - } - - It 'rewrites the header verb when ChangedFileCount is 0' { - $out = Format-PackageMenu -Finding (NewNoChangesFinding -Folder 'unchanged') -RemainingCount 0 - $out | Should -Match 'Reviewing package \(no detected changes\): unchanged' - $out | Should -Not -Match 'Detected package with unreleased modifications' - } - - It 'relabels option 1 to "View diff (no changes in this package)" when ChangedFileCount is 0' { - $out = Format-PackageMenu -Finding (NewNoChangesFinding) -RemainingCount 0 - $lines = $out -split "`r?`n" | Where-Object { $_ -match '^\s*\d\. ' } - $lines[0] | Should -Match '^\s*1\. View diff \(no changes in this package\)$' - } - - It 'keeps the standard "View diff" label and modifications header when ChangedFileCount is > 0' { - $out = Format-PackageMenu -Finding (NewFinding) -RemainingCount 0 - $lines = $out -split "`r?`n" | Where-Object { $_ -match '^\s*\d\. ' } - $lines[0] | Should -Match '^\s*1\. View diff$' - $out | Should -Match 'Detected package with unreleased modifications' - } - - It 'treats a missing ChangedFileCount as 0 (no-changes flavour)' { - # Defensive: hand-rolled findings without the ChangedFileCount - # property should still render, falling back to the no-changes - # flavour rather than crashing. - $finding = [pscustomobject]@{ - Folder = 'sparse' - PackageName = 'sparse' - CurrentVersion = '1.0.0' - DependencyChains = @() - WorkspaceDependencyChains = @() - } - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $out | Should -Match 'Reviewing package \(no detected changes\)' - $out | Should -Match '1\. View diff \(no changes in this package\)' - } - } - - It 'omits the queued-count suffix when RemainingCount is 0' { - $out = Format-PackageMenu -Finding (NewFinding) -RemainingCount 0 - $out | Should -Not -Match '\(\+\d+ packages? queued\)' - } - - It 'renders "(+1 package queued)" with singular noun when RemainingCount is 1' { - $out = Format-PackageMenu -Finding (NewFinding) -RemainingCount 1 - $out | Should -Match '\(\+1 package queued\)' - $out | Should -Not -Match '\(\+1 packages queued\)' - } - - It 'renders "(+3 packages queued)" with plural noun when RemainingCount is 3' { - $out = Format-PackageMenu -Finding (NewFinding) -RemainingCount 3 - $out | Should -Match '\(\+3 packages queued\)' - } - - It 'renders a "Direct dependents in this workspace:" line listing each direct dependent exactly once' { - # Two chains a->b->d and a->c->d give two distinct direct dependents (b, c). - # The deep "a" root must NOT appear (it depends only transitively). - $finding = NewFinding -Folder 'd' -Chains @(@('a', 'b', 'd'), @('a', 'c', 'd')) - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - - # Single header line (the chain printout was replaced with a single - # comma-separated line to keep large workspaces readable). - ([regex]::Matches($out, 'Direct dependents in this workspace:')).Count | Should -Be 1 - - # Direct dependents only — no transitive root and no full chains. - $out | Should -Match 'Direct dependents in this workspace: b, c' - $out | Should -Not -Match 'in-workspace dependents:' - $out | Should -Not -Match 'pulled in by:' - $out | Should -Not -Match 'potentially affected dependency chains' - - # Drill into just the dependents line to confirm "a" (transitive root) - # is absent — sibling lines like "1.2.3 -> 2.0.0" contain '->' and 'a' - # legitimately, so a blanket -Not -Match against the full menu would - # be wrong. - $lines = $out -split "`r?`n" - $dependentsLine = $lines | Where-Object { $_ -match 'Direct dependents' } | Select-Object -First 1 - $dependentsLine | Should -Not -Match '->' - $dependentsLine | Should -Not -Match '\ba\b' - } - - It 'deduplicates direct dependents when the same direct dependent appears via multiple chains' { - # b is the direct dependent of d via both chains; it must appear once. - # Only one distinct dependent remains, so the singular "Direct dependent" label is used. - $finding = NewFinding -Folder 'd' -Chains @(@('a', 'b', 'd'), @('x', 'b', 'd')) - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $out | Should -Match 'Direct dependent in this workspace: b\b' - $out | Should -Not -Match 'Direct dependents in this workspace:' - ([regex]::Matches($out, '\bb\b')).Count | Should -Be 1 - } - - It 'uses the singular "Direct dependent" label when exactly one direct dependent is listed' { - # `,@(...)` forces PowerShell to treat the single chain as an array of - # one chain rather than flattening it into a single chain of strings. - $finding = NewFinding -Folder 'd' -Chains @(, @('a', 'd')) - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $out | Should -Match 'Direct dependent in this workspace: a\b' - $out | Should -Not -Match 'Direct dependents in this workspace:' - } - - It 'uses the plural "Direct dependents" label when two or more direct dependents are listed' { - $finding = NewFinding -Folder 'd' -Chains @(@('a', 'd'), @('b', 'd')) - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $out | Should -Match 'Direct dependents in this workspace: a, b\b' - $out | Should -Not -Match 'Direct dependent in this workspace:' - } - - It 'lists the five menu options in the exact order and wording from the spec' { - $out = Format-PackageMenu -Finding (NewFinding) -RemainingCount 0 - $lines = $out -split "`r?`n" | Where-Object { $_ -match '^\s*\d\. ' } - $lines.Count | Should -Be 5 - $lines[0] | Should -Match '^\s*1\. View diff$' - $lines[1] | Should -Match '^\s*2\. No material changes - release only if another package requires it$' - # Options 3-5 now carry a concrete version transition; precise transition asserted in dedicated tests below. - $lines[2] | Should -Match '^\s*3\. Release as breaking change \(.+\)$' - $lines[3] | Should -Match '^\s*4\. Release as non-breaking change \(.+\)$' - $lines[4] | Should -Match '^\s*5\. Release as patch \(.+\)$' - } - - It 'renders concrete x.y.z -> (next) transitions for a >=1.x.y package' { - $out = Format-PackageMenu -Finding (NewFinding -CurrentVersion '1.2.3') -RemainingCount 0 - $lines = $out -split "`r?`n" - $lines | Should -Contain ' 3. Release as breaking change (1.2.3 -> 2.0.0)' - $lines | Should -Contain ' 4. Release as non-breaking change (1.2.3 -> 1.3.0)' - $lines | Should -Contain ' 5. Release as patch (1.2.3 -> 1.2.4)' - } - - It 'hides option 5 on 0.x.y packages because non-breaking and patch collapse to the same numeric increment' { - $out = Format-PackageMenu -Finding (NewFinding -CurrentVersion '0.1.2') -RemainingCount 0 - $lines = $out -split "`r?`n" - $lines | Should -Contain ' 3. Release as breaking change (0.1.2 -> 0.2.0)' - $lines | Should -Contain ' 4. Release as non-breaking change (0.1.2 -> 0.1.3)' - # Option 5 must not appear at all on 0.x.y — both "patch" and "non-breaking" - # produce the same numeric increment under Cargo semver, so the menu only - # offers the surviving distinct choice. - $out | Should -Not -Match '^\s*5\. ' - $out | Should -Not -Match 'Release as patch' - } - - It 'hides options 4 AND 5 on 0.0.x packages and emits a "starts with 0.0." hint' { - # On 0.0.x every change type collapses to the same 0.0.(x+1) numeric - # increment, so non-breaking and patch are both indistinguishable from - # breaking — Cargo treats every release at this version range as a - # breaking change. The menu reflects that by hiding both choices. - $out = Format-PackageMenu -Finding (NewFinding -CurrentVersion '0.0.5') -RemainingCount 0 - $lines = $out -split "`r?`n" - $lines | Should -Contain ' 3. Release as breaking change (0.0.5 -> 0.0.6)' - $out | Should -Not -Match '^\s*4\. ' - $out | Should -Not -Match 'Release as non-breaking' - $out | Should -Not -Match '^\s*5\. ' - $out | Should -Not -Match 'Release as patch' - $out | Should -Match 'all releases are considered breaking changes for package versions starting with `0\.0\.`' - } - - It 'does NOT emit the "0.0." hint on 0.x.y (y >= 1) packages' { - # 0.1.2 still has a meaningful non-breaking option (0.1.3), so the - # hint would be misleading. - $out = Format-PackageMenu -Finding (NewFinding -CurrentVersion '0.1.2') -RemainingCount 0 - $out | Should -Not -Match 'starts with `0\.0\.`' - $out | Should -Not -Match 'all releases are considered breaking' - } - - It 'does NOT emit the "0.0." hint on stable >= 1.x.y packages' { - $out = Format-PackageMenu -Finding (NewFinding -CurrentVersion '1.2.3') -RemainingCount 0 - $out | Should -Not -Match 'starts with `0\.0\.`' - $out | Should -Not -Match 'all releases are considered breaking' - } - - It 'falls back to "(breaking)" / "(non-breaking)" / "(patch)" hints when CurrentVersion is missing or blank' { - # Defensive: hand-rolled findings without CurrentVersion should still render the menu, not crash. - $finding = [pscustomobject]@{ - Folder = 'ohno' - PackageName = 'ohno' - ChangedFileCount = 1 - DependencyChains = @(, @('a', 'ohno')) - WorkspaceDependencyChains = @(, @('a', 'ohno')) - } - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $lines = $out -split "`r?`n" - $lines | Should -Contain ' 3. Release as breaking change (breaking)' - $lines | Should -Contain ' 4. Release as non-breaking change (non-breaking)' - $lines | Should -Contain ' 5. Release as patch (patch)' - } - - It 'does NOT include any "files changed" / numeric file-count metric' { - # Materiality is communicated via the View Diff option; a raw count - # would be misleading visual noise. - $finding = NewFinding -Folder 'ohno' -Chains @(@('a', 'ohno')) - $finding.ChangedFileCount = 42 - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $out | Should -Not -Match 'files? changed' - $out | Should -Not -Match '\b42\b' - } - - Context 'empty WorkspaceDependencyChains' { - - # WorkspaceDependencyChains is empty when no other workspace package - # transitively depends on the package under review. The menu reports - # that absence plainly so the reviewer knows the release blast radius - # is limited to this package alone (modulo external consumers). - - It 'replaces the dependents line with "no in-workspace dependents" when the workspace list is empty' { - $finding = [pscustomobject]@{ - Folder = 'lonely' - PackageName = 'lonely' - CurrentVersion = '0.1.0' - ChangedFileCount = 1 - DependencyChains = @() - WorkspaceDependencyChains = @() - } - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $out | Should -Not -Match 'Direct dependents in this workspace:' - $out | Should -Match 'no in-workspace dependents' - } - - It 'renders the direct-dependents line even when DependencyChains (release-set rooted) is empty' { - # Stub findings produced by Get-UnreleasedModifiedDependencies in - # -IncludeAllModifiedAsRoots mode have DependencyChains = @() but - # may still have workspace-rooted chains via the reverse-dep walk. - $finding = [pscustomobject]@{ - Folder = 'd' - PackageName = 'd' - CurrentVersion = '0.1.0' - ChangedFileCount = 1 - DependencyChains = @() - WorkspaceDependencyChains = @(, @('a', 'd')) - } - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - $out | Should -Match 'Direct dependent in this workspace: a' - $out | Should -Not -Match 'no in-workspace dependents' - } - - It 'ignores DependencyChains entirely when WorkspaceDependencyChains is populated (regression: menu reads only the workspace view)' { - $finding = [pscustomobject]@{ - Folder = 'd' - PackageName = 'd' - CurrentVersion = '0.1.0' - ChangedFileCount = 1 - # Deliberately distinct chains to confirm the menu reads only WorkspaceDependencyChains. - DependencyChains = @(, @('release_set_root', 'd')) - WorkspaceDependencyChains = @(, @('a', 'b', 'd')) - } - $out = Format-PackageMenu -Finding $finding -RemainingCount 0 - # Direct dependent of d via the workspace chain is b (not release_set_root). - $out | Should -Match 'Direct dependent in this workspace: b\b' - $out | Should -Not -Match 'release_set_root' - } - } -} - -# --------------------------------------------------------------------------- -# Get-UnreleasedModifiedDependencies (planned release details) -# --------------------------------------------------------------------------- - -Describe 'Get-UnreleasedModifiedDependencies: planned release details' { - - It 'carries the effective release level and version into in-plan findings' { - Mock -CommandName Get-WorkspacePackages -MockWith { - @([pscustomobject]@{ - Folder = 'anyspawn' - Name = 'anyspawn' - Version = '0.6.0' - Published = $true - Deps = @() - IsProcMacroOnly = $false - }) - } - Mock -CommandName Get-InWorkspaceDependencyChains -MockWith { @() } - - $resolved = @{ - anyspawn = [pscustomobject]@{ - Folder = 'anyspawn' - Name = 'anyspawn' - CurrentVersion = '0.6.0' - EffectiveChangeType = 'patch' - EffectiveTargetVersion = '0.6.1' - Source = 'cascade' - } - } - - $finding = @(Get-UnreleasedModifiedDependencies ` - -RepoRoot $TestDrive ` - -ResolvedReleaseSet $resolved ` - -ModifiedSnapshot @{ anyspawn = 1 })[0] - - $finding.InReleaseSet | Should -BeTrue - $finding.PlannedCurrentVersion | Should -Be '0.6.0' - $finding.EffectiveChangeType | Should -Be 'patch' - $finding.EffectiveTargetVersion | Should -Be '0.6.1' - } -} - -# --------------------------------------------------------------------------- -# Test-IsPatchOptionRedundant (pure semver-rule helper) -# --------------------------------------------------------------------------- - -Describe 'Test-IsPatchOptionRedundant' { - It 'returns $false for stable >=1.x.y versions' { - Test-IsPatchOptionRedundant -CurrentVersion '1.0.0' | Should -BeFalse - Test-IsPatchOptionRedundant -CurrentVersion '1.2.3' | Should -BeFalse - Test-IsPatchOptionRedundant -CurrentVersion '42.7.0' | Should -BeFalse - } - - It 'returns $true for 0.x.y versions (minor and patch collapse under Cargo semver)' { - Test-IsPatchOptionRedundant -CurrentVersion '0.1.0' | Should -BeTrue - Test-IsPatchOptionRedundant -CurrentVersion '0.4.7' | Should -BeTrue - } - - It 'returns $true for 0.0.x versions (every change collapses to patch)' { - Test-IsPatchOptionRedundant -CurrentVersion '0.0.1' | Should -BeTrue - Test-IsPatchOptionRedundant -CurrentVersion '0.0.42' | Should -BeTrue - } - - It 'returns $false (conservative default) when the version is missing, null, or whitespace' { - Test-IsPatchOptionRedundant -CurrentVersion '' | Should -BeFalse - Test-IsPatchOptionRedundant -CurrentVersion $null | Should -BeFalse - Test-IsPatchOptionRedundant -CurrentVersion ' ' | Should -BeFalse - } -} - -# --------------------------------------------------------------------------- -# Test-IsNonBreakingOptionRedundant (pure semver-rule helper) -# --------------------------------------------------------------------------- - -Describe 'Test-IsNonBreakingOptionRedundant' { - It 'returns $false for stable >=1.x.y versions (non-breaking and breaking differ)' { - Test-IsNonBreakingOptionRedundant -CurrentVersion '1.0.0' | Should -BeFalse - Test-IsNonBreakingOptionRedundant -CurrentVersion '1.2.3' | Should -BeFalse - Test-IsNonBreakingOptionRedundant -CurrentVersion '42.7.0' | Should -BeFalse - } - - It 'returns $false for 0.x.y (y >= 1) versions (breaking bumps minor, non-breaking bumps patch)' { - Test-IsNonBreakingOptionRedundant -CurrentVersion '0.1.0' | Should -BeFalse - Test-IsNonBreakingOptionRedundant -CurrentVersion '0.4.7' | Should -BeFalse - } - - It 'returns $true for 0.0.x versions (every change collapses to the same patch bump)' { - Test-IsNonBreakingOptionRedundant -CurrentVersion '0.0.1' | Should -BeTrue - Test-IsNonBreakingOptionRedundant -CurrentVersion '0.0.42' | Should -BeTrue - } - - It 'returns $false (conservative default) when the version is missing, null, or whitespace' { - Test-IsNonBreakingOptionRedundant -CurrentVersion '' | Should -BeFalse - Test-IsNonBreakingOptionRedundant -CurrentVersion $null | Should -BeFalse - Test-IsNonBreakingOptionRedundant -CurrentVersion ' ' | Should -BeFalse - } -} - -# --------------------------------------------------------------------------- -# Get-PackageReleaseDecision (input-validation loop) -# --------------------------------------------------------------------------- - -Describe 'Get-PackageReleaseDecision' { - - BeforeAll { - function script:NewFinding { - param( - [string]$Folder = 'ohno', - [AllowEmptyString()][AllowNull()][string]$CurrentVersion - ) - return [pscustomobject]@{ - Folder = $Folder - PackageName = $Folder - CurrentVersion = $CurrentVersion - ChangedFileCount = 1 - DependencyChains = @(, @('a', $Folder)) - WorkspaceDependencyChains = @(, @('a', $Folder)) - } - } - - # Helper: install a Read-Host mock that returns scripted answers in order. - function script:SetReadHostQueue { - param([Parameter(Mandatory = $true)][object[]]$Answers) - $script:RH_Queue = [System.Collections.Queue]::new() - foreach ($a in $Answers) { $script:RH_Queue.Enqueue($a) } - $script:RH_PromptsObserved = [System.Collections.Generic.List[string]]::new() - Mock -CommandName Read-Host -MockWith { - param([string]$Prompt) - $script:RH_PromptsObserved.Add($Prompt) | Out-Null - if ($script:RH_Queue.Count -eq 0) { - throw "Read-Host mock ran out of answers (prompt: '$Prompt')" - } - return $script:RH_Queue.Dequeue() - } - } - } - - BeforeEach { - Mock -CommandName Show-PackageDiff -MockWith { } - # Suppress menu rendering and Write-Host noise for assertions on prompts/output. - Mock -CommandName Show-PackageMenu -MockWith { } - } - - Context 'happy-path single-keystroke answers' { - It "returns 'ignore' for input '2'" { - SetReadHostQueue -Answers @('2') - $r = Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'ignore' - } - It "returns 'breaking' for input '3'" { - SetReadHostQueue -Answers @('3') - $r = Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'breaking' - } - It "returns 'non-breaking' for input '4'" { - SetReadHostQueue -Answers @('4') - $r = Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'non-breaking' - } - It "returns 'patch' for input '5'" { - SetReadHostQueue -Answers @('5') - $r = Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'patch' - } - } - - Context 'invalid input handling' { - It 'silently re-prompts on empty input (no warning emitted)' { - SetReadHostQueue -Answers @('', '2') - $warn = $null - $r = Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive 3>&1 6>&1 - # The returned hashtable should be unwrapped from the captured stream. - # We assert directly via a fresh call below. - SetReadHostQueue -Answers @('', '2') - $r2 = Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive - $r2.Action | Should -Be 'ignore' - $script:RH_PromptsObserved.Count | Should -Be 2 - } - - It "complains then re-prompts on '12' (whole-string check, not first char)" { - SetReadHostQueue -Answers @('12', '2') - $out = & { Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive } 6>&1 - # The hashtable is the last item written (6>&1 merges Information stream). - $actionItem = $out | Where-Object { $_ -is [hashtable] } | Select-Object -Last 1 - $actionItem.Action | Should -Be 'ignore' - $script:RH_PromptsObserved.Count | Should -Be 2 - ($out | Out-String) | Should -Match "Invalid choice '12'" - } - - It "complains then re-prompts on whitespace-only input ' '" { - SetReadHostQueue -Answers @(' ', '2') - # ' '.Trim() = '' so this should follow the silent-reprompt path, - # NOT the invalid-choice path. We assert no warning. - $out = & { Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive } 6>&1 - $actionItem = $out | Where-Object { $_ -is [hashtable] } | Select-Object -Last 1 - $actionItem.Action | Should -Be 'ignore' - ($out | Out-String) | Should -Not -Match 'Invalid choice' - } - - It "complains then re-prompts on letter input 'x'" { - SetReadHostQueue -Answers @('x', '2') - $out = & { Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive } 6>&1 - $actionItem = $out | Where-Object { $_ -is [hashtable] } | Select-Object -Last 1 - $actionItem.Action | Should -Be 'ignore' - ($out | Out-String) | Should -Match "Invalid choice 'x'" - } - - It "complains then re-prompts on '1 2' (extra characters)" { - SetReadHostQueue -Answers @('1 2', '2') - $out = & { Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive } 6>&1 - $actionItem = $out | Where-Object { $_ -is [hashtable] } | Select-Object -Last 1 - $actionItem.Action | Should -Be 'ignore' - ($out | Out-String) | Should -Match "Invalid choice '1 2'" - } - - It "complains then re-prompts on '2.0'" { - SetReadHostQueue -Answers @('2.0', '2') - $out = & { Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive } 6>&1 - $actionItem = $out | Where-Object { $_ -is [hashtable] } | Select-Object -Last 1 - $actionItem.Action | Should -Be 'ignore' - ($out | Out-String) | Should -Match "Invalid choice '2.0'" - } - - It 'absorbs a chain of bad inputs and still returns the valid choice at the end' { - SetReadHostQueue -Answers @('', 'x', '12', ' ', '5') - $r = Get-PackageReleaseDecision -Finding (NewFinding) -RemainingCount 0 -RepoRoot $TestDrive 6>$null - $r.Action | Should -Be 'patch' - $script:RH_PromptsObserved.Count | Should -Be 5 - } - } - - Context 'View Diff (choice 1) re-prompts without re-rendering the menu' { - It "calls Show-PackageDiff once when the user picks '1' then '4', menu rendered only once" { - SetReadHostQueue -Answers @('1', '4') - $r = Get-PackageReleaseDecision -Finding (NewFinding -Folder 'b') -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'non-breaking' - Should -Invoke -CommandName Show-PackageDiff -Times 1 -Exactly -ParameterFilter { $Folder -eq 'b' } - # Menu rendered: 1 initial only — diff selection does NOT re-render - # the menu (the options are still visible in scrollback above). - Should -Invoke -CommandName Show-PackageMenu -Times 1 -Exactly - # But the Read-Host prompt IS observed twice: once initial, once - # after the diff is shown. - $script:RH_PromptsObserved.Count | Should -Be 2 - } - - It "calls Show-PackageDiff twice when the user picks '1', '1', '4', menu still rendered only once" { - SetReadHostQueue -Answers @('1', '1', '4') - $r = Get-PackageReleaseDecision -Finding (NewFinding -Folder 'b') -RemainingCount 2 -RepoRoot $TestDrive - $r.Action | Should -Be 'non-breaking' - Should -Invoke -CommandName Show-PackageDiff -Times 2 -Exactly - Should -Invoke -CommandName Show-PackageMenu -Times 1 -Exactly - $script:RH_PromptsObserved.Count | Should -Be 3 - } - } - - Context 'prompt format' { - It "includes the package name in the Read-Host prompt for scrollback / scenario disambiguation" { - SetReadHostQueue -Answers @('2') - Get-PackageReleaseDecision -Finding (NewFinding -Folder 'mypkg') -RemainingCount 0 -RepoRoot $TestDrive | Out-Null - $script:RH_PromptsObserved[0] | Should -Match "Choose option for 'mypkg'" - } - - It "advertises the full [1-5] range when CurrentVersion is unknown" { - SetReadHostQueue -Answers @('2') - Get-PackageReleaseDecision -Finding (NewFinding -Folder 'mypkg') -RemainingCount 0 -RepoRoot $TestDrive | Out-Null - $script:RH_PromptsObserved[0] | Should -Match '\[1-5\]' - } - - It "advertises the narrower [1-4] range when option 5 is hidden (0.x.y package)" { - SetReadHostQueue -Answers @('2') - $finding = NewFinding -Folder 'mypkg' -CurrentVersion '0.1.2' - Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive | Out-Null - $script:RH_PromptsObserved[0] | Should -Match '\[1-4\]' - } - - It "advertises the narrowest [1-3] range when options 4 AND 5 are hidden (0.0.x package)" { - SetReadHostQueue -Answers @('2') - $finding = NewFinding -Folder 'mypkg' -CurrentVersion '0.0.5' - Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive | Out-Null - $script:RH_PromptsObserved[0] | Should -Match '\[1-3\]' - } - } - - Context 'option 5 is rejected when hidden (0.x.y package)' { - It "treats '5' as invalid and re-prompts, message references the narrower range" { - SetReadHostQueue -Answers @('5', '4') - $finding = NewFinding -Folder 'pkg' -CurrentVersion '0.1.2' - $out = & { Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive } 6>&1 - $actionItem = $out | Where-Object { $_ -is [hashtable] } | Select-Object -Last 1 - $actionItem.Action | Should -Be 'non-breaking' - $script:RH_PromptsObserved.Count | Should -Be 2 - ($out | Out-String) | Should -Match "Invalid choice '5'" - ($out | Out-String) | Should -Match 'from 1 to 4' - } - - It "still accepts '4' (non-breaking) on a 0.x.y package" { - SetReadHostQueue -Answers @('4') - $finding = NewFinding -Folder 'pkg' -CurrentVersion '0.1.2' - $r = Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'non-breaking' - } - - It "still accepts '3' (breaking) on a 0.x.y package" { - SetReadHostQueue -Answers @('3') - $finding = NewFinding -Folder 'pkg' -CurrentVersion '0.1.2' - $r = Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'breaking' - } - } - - Context 'options 4 AND 5 are rejected when hidden (0.0.x package)' { - It "treats '4' as invalid and re-prompts, message references the narrowest range" { - SetReadHostQueue -Answers @('4', '3') - $finding = NewFinding -Folder 'pkg' -CurrentVersion '0.0.5' - $out = & { Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive } 6>&1 - $actionItem = $out | Where-Object { $_ -is [hashtable] } | Select-Object -Last 1 - $actionItem.Action | Should -Be 'breaking' - $script:RH_PromptsObserved.Count | Should -Be 2 - ($out | Out-String) | Should -Match "Invalid choice '4'" - ($out | Out-String) | Should -Match 'from 1 to 3' - } - - It "treats '5' as invalid and re-prompts on a 0.0.x package" { - SetReadHostQueue -Answers @('5', '3') - $finding = NewFinding -Folder 'pkg' -CurrentVersion '0.0.5' - $out = & { Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive } 6>&1 - $actionItem = $out | Where-Object { $_ -is [hashtable] } | Select-Object -Last 1 - $actionItem.Action | Should -Be 'breaking' - $script:RH_PromptsObserved.Count | Should -Be 2 - ($out | Out-String) | Should -Match "Invalid choice '5'" - ($out | Out-String) | Should -Match 'from 1 to 3' - } - - It "still accepts '3' (breaking) on a 0.0.x package" { - SetReadHostQueue -Answers @('3') - $finding = NewFinding -Folder 'pkg' -CurrentVersion '0.0.5' - $r = Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'breaking' - } - - It "still accepts '2' (ignore) on a 0.0.x package" { - SetReadHostQueue -Answers @('2') - $finding = NewFinding -Folder 'pkg' -CurrentVersion '0.0.5' - $r = Get-PackageReleaseDecision -Finding $finding -RemainingCount 0 -RepoRoot $TestDrive - $r.Action | Should -Be 'ignore' - } - } -} - -# --------------------------------------------------------------------------- -# Show-PackageDiff (orchestrator: diff -> temp file -> opener + tracking) -# --------------------------------------------------------------------------- - -Describe 'Show-PackageDiff' { - - BeforeEach { - # Reset the tracking list so tests don't leak into each other. - $script:TempPackageDiffPaths = [System.Collections.Generic.List[string]]::new() - Mock -CommandName Get-PackageDiffText -MockWith { return "diff body for $Folder`n" } - Mock -CommandName Open-PathWithPreferredEditor -MockWith { } - } - - It 'writes the diff to a file and invokes the opener with the same path' { - Mock -CommandName Get-PreferredEditor -MockWith { - [pscustomobject]@{ Kind = 'system'; FileExtension = '.txt' } - } - # Force the temp file into $TestDrive so we can inspect / clean up. - Mock -CommandName Save-PackageDiffToTempFile -MockWith { - $p = Join-Path $TestDrive ("pkg-" + [guid]::NewGuid().ToString('N') + '.txt') - Set-Content -LiteralPath $p -Value $DiffText -NoNewline - return $p - } - - Show-PackageDiff -RepoRoot $TestDrive -Folder 'bytesbuf' 6>$null - - $script:TempPackageDiffPaths.Count | Should -Be 1 - $written = $script:TempPackageDiffPaths[0] - Test-Path -LiteralPath $written | Should -BeTrue - (Get-Content -LiteralPath $written -Raw) | Should -Be "diff body for bytesbuf`n" - - Should -Invoke -CommandName Open-PathWithPreferredEditor -Times 1 -Exactly -ParameterFilter { $Path -eq $written -and $Editor.Kind -eq 'system' } - } - - It "uses the preferred editor's extension when saving (e.g. .diff for VS Code)" { - Mock -CommandName Get-PreferredEditor -MockWith { - [pscustomobject]@{ Kind = 'code'; FileExtension = '.diff' } - } - # Spy on Save-PackageDiffToTempFile to capture the extension argument. - Mock -CommandName Save-PackageDiffToTempFile -MockWith { - $p = Join-Path $TestDrive ("pkg-" + [guid]::NewGuid().ToString('N') + $Extension) - Set-Content -LiteralPath $p -Value $DiffText -NoNewline - return $p - } - - Show-PackageDiff -RepoRoot $TestDrive -Folder 'bytesbuf' 6>$null - - Should -Invoke -CommandName Save-PackageDiffToTempFile -Times 1 -Exactly -ParameterFilter { $Extension -eq '.diff' } - $script:TempPackageDiffPaths[0] | Should -BeLike '*.diff' - Should -Invoke -CommandName Open-PathWithPreferredEditor -Times 1 -Exactly -ParameterFilter { $Editor.Kind -eq 'code' } - } - - It 'appends to $script:TempPackageDiffPaths even when the opener fails silently' { - Mock -CommandName Get-PreferredEditor -MockWith { - [pscustomobject]@{ Kind = 'system'; FileExtension = '.txt' } - } - Mock -CommandName Save-PackageDiffToTempFile -MockWith { - $p = Join-Path $TestDrive ("pkg-" + [guid]::NewGuid().ToString('N') + '.txt') - Set-Content -LiteralPath $p -Value $DiffText -NoNewline - return $p - } - Mock -CommandName Open-PathWithPreferredEditor -MockWith { } # no-op, simulates failure absorbed by helper - - Show-PackageDiff -RepoRoot $TestDrive -Folder 'a' 6>$null - Show-PackageDiff -RepoRoot $TestDrive -Folder 'b' 6>$null - $script:TempPackageDiffPaths.Count | Should -Be 2 - } -} - -# --------------------------------------------------------------------------- -# Save-PackageDiffToTempFile (pure-ish: writes a file, returns path) -# --------------------------------------------------------------------------- - -Describe 'Save-PackageDiffToTempFile' { - It 'defaults to .txt and writes the diff text under the requested directory' { - $dir = Join-Path $TestDrive 'savediff' - $p = Save-PackageDiffToTempFile -Folder 'bytesbuf_io' -DiffText "hello`nworld" -Directory $dir - $p | Should -BeLike (Join-Path $dir 'oxi-pkg-diff-bytesbuf_io-*.txt') - (Get-Content -LiteralPath $p -Raw) | Should -Be "hello`nworld" - } - - It 'honours an explicit -Extension (e.g. .diff for VS Code)' { - $dir = Join-Path $TestDrive 'savediff-ext' - $p = Save-PackageDiffToTempFile -Folder 'bytesbuf_io' -DiffText 'x' -Directory $dir -Extension '.diff' - $p | Should -BeLike (Join-Path $dir 'oxi-pkg-diff-bytesbuf_io-*.diff') - } - - It 'normalises an extension passed without the leading dot' { - $dir = Join-Path $TestDrive 'savediff-nodot' - $p = Save-PackageDiffToTempFile -Folder 'a' -DiffText 'x' -Directory $dir -Extension 'diff' - $p | Should -BeLike (Join-Path $dir 'oxi-pkg-diff-a-*.diff') - } - - It 'sanitises folder names containing characters not allowed in file names' { - $dir = Join-Path $TestDrive 'savediff2' - $p = Save-PackageDiffToTempFile -Folder 'weird/pkg name' -DiffText 'x' -Directory $dir - (Split-Path $p -Leaf) | Should -Match '^oxi-pkg-diff-weird_pkg_name-[0-9a-f]+\.txt$' - } -} - -# --------------------------------------------------------------------------- -# Get-PreferredEditor (VS Code -> code-insiders -> system fallback) -# --------------------------------------------------------------------------- - -Describe 'Get-PreferredEditor' { - - It "returns 'code' + .diff when `code` is on PATH" { - Mock -CommandName Get-Command -MockWith { - if ($Name -eq 'code') { return [pscustomobject]@{ Name = 'code' } } - return $null - } - $e = Get-PreferredEditor - $e.Kind | Should -Be 'code' - $e.FileExtension | Should -Be '.diff' - } - - It "prefers 'code' over 'code-insiders' when both are on PATH" { - Mock -CommandName Get-Command -MockWith { - return [pscustomobject]@{ Name = $Name } - } - $e = Get-PreferredEditor - $e.Kind | Should -Be 'code' - } - - It "returns 'code-insiders' + .diff when only insiders is on PATH" { - Mock -CommandName Get-Command -MockWith { - if ($Name -eq 'code') { return $null } - if ($Name -eq 'code-insiders') { return [pscustomobject]@{ Name = 'code-insiders' } } - return $null - } - $e = Get-PreferredEditor - $e.Kind | Should -Be 'code-insiders' - $e.FileExtension | Should -Be '.diff' - } - - It "returns 'system' + .txt when no VS Code variant is on PATH" { - Mock -CommandName Get-Command -MockWith { return $null } - $e = Get-PreferredEditor - $e.Kind | Should -Be 'system' - $e.FileExtension | Should -Be '.txt' - } -} - -# --------------------------------------------------------------------------- -# Open-PathWithPreferredEditor (dispatch on editor kind) -# --------------------------------------------------------------------------- - -Describe 'Open-PathWithPreferredEditor' { - BeforeEach { - # Default safety net so a flaky test doesn't actually try to launch VS Code or the OS opener. - Mock -CommandName Start-Process -MockWith { } - } - - It "invokes 'code' when the editor kind is 'code'" { - # Mocking external executables: Pester can mock cmdlets/functions, not arbitrary native commands, - # so we capture the dispatch by mocking Get-Variable for $IsWindows (irrelevant here) and asserting - # behavior indirectly via the LASTEXITCODE check path. The simplest assertion is that the function - # neither throws nor writes a warning when the (mocked) external command succeeds. - function script:code { param([string]$p) $global:LASTEXITCODE = 0 } - Mock -CommandName Write-Warning -MockWith { } - try { - { Open-PathWithPreferredEditor -Path 'C:\temp\demo.diff' -Editor ([pscustomobject]@{ Kind = 'code'; FileExtension = '.diff' }) } | Should -Not -Throw - Should -Invoke -CommandName Write-Warning -Times 0 -Exactly - } finally { - Remove-Item function:script:code -ErrorAction SilentlyContinue - } - } - - It "warns and does not throw when 'code' exits with a non-zero code" { - function script:code { param([string]$p) $global:LASTEXITCODE = 7 } - Mock -CommandName Write-Warning -MockWith { } - try { - { Open-PathWithPreferredEditor -Path 'C:\temp\demo.diff' -Editor ([pscustomobject]@{ Kind = 'code'; FileExtension = '.diff' }) } | Should -Not -Throw - Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'code exited with code 7' } - } finally { - Remove-Item function:script:code -ErrorAction SilentlyContinue - } - } - - Context "system-kind dispatch on Windows" -Skip:(-not ((Get-Variable -Name IsWindows -Scope Global -ErrorAction SilentlyContinue) -eq $null -or $IsWindows)) { - It 'falls back to Start-Process for kind = system' { - Open-PathWithPreferredEditor -Path 'C:\temp\demo.txt' -Editor ([pscustomobject]@{ Kind = 'system'; FileExtension = '.txt' }) - Should -Invoke -CommandName Start-Process -Times 1 -Exactly -ParameterFilter { $FilePath -eq 'C:\temp\demo.txt' } - } - - It 'emits a warning and does not throw when Start-Process throws' { - Mock -CommandName Start-Process -MockWith { throw 'no association' } - Mock -CommandName Write-Warning -MockWith { } - { Open-PathWithPreferredEditor -Path 'C:\temp\demo.txt' -Editor ([pscustomobject]@{ Kind = 'system'; FileExtension = '.txt' }) } | Should -Not -Throw - Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'no association' } - } - - It 'resolves the editor on the fly when -Editor is omitted' { - Mock -CommandName Get-PreferredEditor -MockWith { - [pscustomobject]@{ Kind = 'system'; FileExtension = '.txt' } - } - Open-PathWithPreferredEditor -Path 'C:\temp\demo.txt' - Should -Invoke -CommandName Get-PreferredEditor -Times 1 -Exactly - Should -Invoke -CommandName Start-Process -Times 1 -Exactly - } - } -} - -# --------------------------------------------------------------------------- -# Invoke-PlanReview (runaway-cap behaviour) -# --------------------------------------------------------------------------- -# -# The state-signature "no progress" diagnostic that this Describe-block once -# tried to cover is unreachable through the current control flow: every -# iteration body either early-returns (queue empty), updates state via -# ignore (`declined` / `reviewedCascadeAsIs`) or accept (`userTokens`), or -# throws via the switch `default` arm. A direct unit test would therefore -# have to inject a buggy state that no real caller can produce — a -# tautology that adds no signal. The check itself is retained in the -# production code as defense-in-depth against future changes that could -# introduce a state-leak path; see the comment on the signature check in -# Invoke-PlanReview. - -Describe 'Invoke-PlanReview iteration-cap behaviour' { - - BeforeEach { - # Silence the chatty interactive output; we only need the final return - # value and the Write-Warning emitted on the cap path. - Mock -CommandName Write-Host -MockWith { } -ModuleName $null - - # 1 published package in the synthetic workspace => $runawayCap = 10. - # The exact baseline is irrelevant because Resolve-ReleaseSet is mocked, - # so anything that satisfies the Published filter works. - Mock -CommandName Get-WorkspacePackages -MockWith { - [pscustomobject]@{ - Name = 'p1' - Folder = 'p1' - Version = '1.0.0' - Published = $true - Deps = @() - } - } - - # Always surface a single finding for a package not in the initial plan. - # Returned as a stream (not a wrapped array) per the file-level mock - # pitfall note. - Mock -CommandName Get-UnreleasedModifiedDependencies -MockWith { - [pscustomobject]@{ - Folder = 'extra' - PackageName = 'extra' - CurrentVersion = '1.0.0' - ChangedFileCount = 1 - DependencyChains = @(, @('p1', 'extra')) - InReleaseSet = $false - } - } - - # Always accept as non-breaking. Combined with a perpetually-surfacing - # finding, this drives the loop to its runaway-cap (10x published - # package count) before exiting. Each iteration appends a fresh token, - # so the state signature changes — exercising the cap-return path - # rather than the no-progress detection path. - Mock -CommandName Get-PackageReleaseDecision -MockWith { - @{ Action = 'non-breaking' } - } - } - - It 'returns a plan that includes the token accepted on the final (cap-bound) iteration' { - # State-aware Resolve-ReleaseSet: reflects the size of $ParsedTokens so - # the post-cap re-resolve picks up the extra token added inside the loop. - Mock -CommandName Resolve-ReleaseSet -MockWith { - $entries = @() - foreach ($t in $ParsedTokens) { - $entries += [pscustomobject]@{ - Folder = $t.Name - Name = $t.Name - CurrentVersion = '1.0.0' - EffectiveChangeType = 'non-breaking' - EffectiveTargetVersion = '1.1.0' - Source = if ($t.Name -eq 'p1') { 'user' } else { 'cascade' } - AutoUpgraded = $false - CascadeReasons = New-Object 'System.Collections.Generic.List[object]' - RawToken = $t.RawToken - } - } - $entries - } - - Mock -CommandName Write-Warning -MockWith { } - - $initialToken = [pscustomobject]@{ - Name = 'p1' - RequestedChangeType = 'non-breaking' - RequestedTargetVersion = $null - RawToken = 'p1@nonbreaking' - } - - $plan = Invoke-PlanReview ` - -RepoRoot $TestDrive ` - -ParsedTokens @($initialToken) ` - -WorkspaceBaseline @() - - # The runaway-cap warning fires exactly once, proving we exited via - # the cap path (not the queue-drained path or the no-progress throw). - Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter { - $Message -match 'runaway-cap' - } - - # Resolve-ReleaseSet is called once per in-loop iteration AND again - # before the cap return — that final call is the regression fix - # ensuring the cap-iteration acceptance is reflected in the returned - # plan rather than being silently dropped. Without -Exactly, -Times - # means "at least". - Should -Invoke -CommandName Resolve-ReleaseSet -Times 2 - - # The returned plan reflects the final acceptance: both the initial - # user-token and the cap-iteration acceptance are present. - $plan | Should -Not -BeNullOrEmpty - $plan.ContainsKey('p1') | Should -BeTrue - $plan.ContainsKey('extra') | Should -BeTrue - $plan['extra'].EffectiveChangeType | Should -Be 'non-breaking' - } -} - -# --------------------------------------------------------------------------- -# Invoke-PlanReview (-Mode 'all-changed' behaviour) -# --------------------------------------------------------------------------- - -Describe 'Invoke-PlanReview -Mode all-changed' { - - BeforeEach { - # Same chatty-output silencing as the iteration-cap describe block. - Mock -CommandName Write-Host -MockWith { } -ModuleName $null - - Mock -CommandName Get-WorkspacePackages -MockWith { - [pscustomobject]@{ - Name = 'p1' - Folder = 'p1' - Version = '1.0.0' - Published = $true - Deps = @() - } - } - } - - It 'returns @{} without invoking Resolve-ReleaseSet when no userTokens and no findings' { - # Empty $ParsedTokens combined with no findings = nothing to surface. - # The all-changed path must skip Resolve-ReleaseSet (which would throw - # on empty input) and return an empty plan cleanly. - Mock -CommandName Resolve-ReleaseSet -MockWith { - throw 'Resolve-ReleaseSet should not be invoked when Mode=all-changed and userTokens is empty.' - } - Mock -CommandName Get-UnreleasedModifiedDependencies -MockWith { @() } - - $plan = Invoke-PlanReview ` - -RepoRoot $TestDrive ` - -ParsedTokens @() ` - -WorkspaceBaseline @() ` - -Mode 'all-changed' - - $plan | Should -BeOfType ([hashtable]) - $plan.Count | Should -Be 0 - Should -Invoke -CommandName Resolve-ReleaseSet -Times 0 -Exactly - } - - It 'passes -IncludeAllModifiedAsRoots to Get-UnreleasedModifiedDependencies' { - Mock -CommandName Resolve-ReleaseSet -MockWith { throw 'should not be called' } - Mock -CommandName Get-UnreleasedModifiedDependencies -MockWith { @() } - - $plan = Invoke-PlanReview ` - -RepoRoot $TestDrive ` - -ParsedTokens @() ` - -WorkspaceBaseline @() ` - -Mode 'all-changed' - - $plan.Count | Should -Be 0 - Should -Invoke -CommandName Get-UnreleasedModifiedDependencies -Times 1 -Exactly -ParameterFilter { - $IncludeAllModifiedAsRoots -eq $true - } - } - - It 'rejects an unknown -Mode value at parameter binding' { - { - Invoke-PlanReview ` - -RepoRoot $TestDrive ` - -ParsedTokens @() ` - -WorkspaceBaseline @() ` - -Mode 'bogus' - } | Should -Throw - } - - It 'defaults to -Mode targeted when omitted (no -IncludeAllModifiedAsRoots flag)' { - # Regression: existing callers (release-packages.ps1) don't pass -Mode - # and must continue to see targeted behavior with no behavioral drift. - Mock -CommandName Resolve-ReleaseSet -MockWith { - param($ParsedTokens, $WorkspaceBaseline) - $entries = @() - foreach ($t in $ParsedTokens) { - $entries += [pscustomobject]@{ - Folder = $t.Name - Name = $t.Name - CurrentVersion = '1.0.0' - EffectiveChangeType = 'non-breaking' - EffectiveTargetVersion = '1.1.0' - Source = 'user' - AutoUpgraded = $false - CascadeReasons = New-Object 'System.Collections.Generic.List[object]' - RawToken = $t.RawToken - } - } - $entries - } - Mock -CommandName Get-UnreleasedModifiedDependencies -MockWith { @() } - - $tok = [pscustomobject]@{ - Name = 'p1' - RequestedChangeType = 'non-breaking' - RequestedTargetVersion = $null - RawToken = 'p1@nonbreaking' - } - Invoke-PlanReview ` - -RepoRoot $TestDrive ` - -ParsedTokens @($tok) ` - -WorkspaceBaseline @() | Out-Null - - Should -Invoke -CommandName Get-UnreleasedModifiedDependencies -Times 1 -Exactly -ParameterFilter { - -not $IncludeAllModifiedAsRoots - } - } -} - -# --------------------------------------------------------------------------- -# Invoke-PlanReview mandatory proc-macro review precedence -# --------------------------------------------------------------------------- - -Describe 'Invoke-PlanReview mandatory proc-macro review precedence' { - It 're-prompts a previously declined consumer when a breaking proc macro later enters the plan' { - Mock -CommandName Write-Host -MockWith { } -ModuleName $null - - $baseline = @( - [pscustomobject]@{ - Name = 'seed'; Folder = 'seed'; Version = '1.0.0' - Published = $true; Deps = @(); IsProcMacroOnly = $false - } - [pscustomobject]@{ - Name = 'z_macros'; Folder = 'z_macros'; Version = '1.0.0' - Published = $true; Deps = @(); IsProcMacroOnly = $true - } - [pscustomobject]@{ - Name = 'a_consumer'; Folder = 'a_consumer'; Version = '1.0.0' - Published = $true; Deps = @('z_macros'); IsProcMacroOnly = $false - } - ) - Mock -CommandName Get-WorkspacePackages -MockWith { $baseline } - - Mock -CommandName Resolve-ReleaseSet -MockWith { - $entries = New-Object 'System.Collections.Generic.List[object]' - $entries.Add([pscustomobject]@{ - Folder = 'seed'; Name = 'seed'; CurrentVersion = '1.0.0' - EffectiveChangeType = 'patch'; EffectiveTargetVersion = '1.0.1' - Source = 'user'; AutoUpgraded = $false - PinHonoredAgainstCascade = $false; IsProcMacroOnly = $false - RequiresManualSemverReview = $false - CascadeReasons = New-Object 'System.Collections.Generic.List[object]' - RawToken = 'seed@patch' - }) - - if (@($ParsedTokens.Name) -contains 'z_macros') { - $entries.Add([pscustomobject]@{ - Folder = 'z_macros'; Name = 'z_macros'; CurrentVersion = '1.0.0' - EffectiveChangeType = 'breaking'; EffectiveTargetVersion = '2.0.0' - Source = 'user'; AutoUpgraded = $false - PinHonoredAgainstCascade = $false; IsProcMacroOnly = $true - RequiresManualSemverReview = $true - CascadeReasons = New-Object 'System.Collections.Generic.List[object]' - RawToken = 'z_macros@breaking' - }) - $consumerReasons = New-Object 'System.Collections.Generic.List[object]' - $consumerReasons.Add([pscustomobject]@{ Target = 'z_macros'; Breaking = $false }) - $entries.Add([pscustomobject]@{ - Folder = 'a_consumer'; Name = 'a_consumer'; CurrentVersion = '1.0.0' - EffectiveChangeType = 'patch'; EffectiveTargetVersion = '1.0.1' - Source = 'cascade'; AutoUpgraded = $false - PinHonoredAgainstCascade = $false; IsProcMacroOnly = $false - RequiresManualSemverReview = $false - CascadeReasons = $consumerReasons; RawToken = $null - }) - } - $entries - } - - # Both findings exist from the beginning. The ordinary consumer is - # deliberately first and is declined before the proc macro is accepted. - Mock -CommandName Get-UnreleasedModifiedDependencies -MockWith { - [pscustomobject]@{ - Folder = 'a_consumer'; PackageName = 'a_consumer' - CurrentVersion = '1.0.0'; InReleaseSet = $false - ChangedFileCount = 1; DependencyChains = @() - WorkspaceDependencyChains = @() - RequiresManualSemverReview = $false - } - [pscustomobject]@{ - Folder = 'z_macros'; PackageName = 'z_macros' - CurrentVersion = '1.0.0'; InReleaseSet = $false - ChangedFileCount = 1; DependencyChains = @() - WorkspaceDependencyChains = @() - RequiresManualSemverReview = $true - ManualSemverReviewKind = 'proc-macro' - ManualSemverReviewSources = @() - } - } - - $script:MandatoryReviewPromptFolders = New-Object 'System.Collections.Generic.List[string]' - Mock -CommandName Get-PackageReleaseDecision -MockWith { - $script:MandatoryReviewPromptFolders.Add($Finding.Folder) - switch ($script:MandatoryReviewPromptFolders.Count) { - 1 { return @{ Action = 'ignore' } } - 2 { return @{ Action = 'breaking' } } - 3 { return @{ Action = 'ignore' } } - default { throw "Unexpected review prompt for '$($Finding.Folder)'." } - } - } - - $initialToken = [pscustomobject]@{ - Name = 'seed'; RequestedChangeType = 'patch' - RequestedTargetVersion = $null; RawToken = 'seed@patch' - } - - $plan = Invoke-PlanReview ` - -RepoRoot $TestDrive ` - -ParsedTokens @($initialToken) ` - -WorkspaceBaseline $baseline - - $script:MandatoryReviewPromptFolders.ToArray() | Should -Be @( - 'a_consumer', - 'z_macros', - 'a_consumer' - ) - $plan['a_consumer'].ManualSemverReviewCompleted | Should -BeTrue - $plan['a_consumer'].ManualSemverReviewSources | Should -Be @('z_macros') - } - - It 'keeps an out-of-plan no-material decision when cascade later adds the proc macro' { - Mock -CommandName Write-Host -MockWith { } -ModuleName $null - - $baseline = @( - [pscustomobject]@{ - Name = 'seed'; Folder = 'seed'; Version = '1.0.0' - Published = $true; Deps = @('macros'); IsProcMacroOnly = $false - } - [pscustomobject]@{ - Name = 'macros'; Folder = 'macros'; Version = '1.0.0' - Published = $true; Deps = @('implementation'); IsProcMacroOnly = $true - } - [pscustomobject]@{ - Name = 'implementation'; Folder = 'implementation'; Version = '1.0.0' - Published = $true; Deps = @(); IsProcMacroOnly = $false - } - ) - Mock -CommandName Get-WorkspacePackages -MockWith { $baseline } - - Mock -CommandName Get-UnreleasedModifiedDependencies -MockWith { - [pscustomobject]@{ - Folder = 'macros'; PackageName = 'macros' - CurrentVersion = '1.0.0' - InReleaseSet = $ResolvedReleaseSet.ContainsKey('macros') - ChangedFileCount = 1; DependencyChains = @() - WorkspaceDependencyChains = @() - RequiresManualSemverReview = $true - ManualSemverReviewKind = 'proc-macro' - ManualSemverReviewSources = @() - } - if (-not $ResolvedReleaseSet.ContainsKey('implementation')) { - [pscustomobject]@{ - Folder = 'implementation'; PackageName = 'implementation' - CurrentVersion = '1.0.0'; InReleaseSet = $false - ChangedFileCount = 1; DependencyChains = @() - WorkspaceDependencyChains = @() - RequiresManualSemverReview = $false - } - } - } - - $script:NoMaterialPromptStates = New-Object 'System.Collections.Generic.List[object]' - Mock -CommandName Get-PackageReleaseDecision -MockWith { - $script:NoMaterialPromptStates.Add([pscustomobject]@{ - Folder = $Finding.Folder - InReleaseSet = [bool]$Finding.InReleaseSet - }) - switch ($Finding.Folder) { - 'macros' { return @{ Action = 'ignore' } } - 'implementation' { return @{ Action = 'patch' } } - default { throw "Unexpected review prompt for '$($Finding.Folder)'." } - } - } - - $initialToken = [pscustomobject]@{ - Name = 'seed'; RequestedChangeType = 'patch' - RequestedTargetVersion = $null; RawToken = 'seed@patch' - } - - $plan = Invoke-PlanReview ` - -RepoRoot $TestDrive ` - -ParsedTokens @($initialToken) ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType { param($folder, $cargoName) 'none' } - - $script:NoMaterialPromptStates.Count | Should -Be 2 - $script:NoMaterialPromptStates[0].Folder | Should -Be 'macros' - $script:NoMaterialPromptStates[0].InReleaseSet | Should -BeFalse - $script:NoMaterialPromptStates[1].Folder | Should -Be 'implementation' - $plan['macros'].Source | Should -Be 'cascade' - $plan['macros'].EffectiveChangeType | Should -Be 'patch' - $plan['macros'].ManualSemverReviewCompleted | Should -BeTrue - } -} - -# --------------------------------------------------------------------------- -# Show-ReleasePlan footer (cascade-upgrade semantics) -# --------------------------------------------------------------------------- - -Describe 'Show-ReleasePlan: cascade-upgrade footer' { - - BeforeAll { - function script:New-PlanEntryForFooterTest { - param( - [string]$Folder, - [string]$Source = 'user', - [bool]$AutoUpgraded = $false, - [bool]$PinHonoredAgainstCascade = $false, - [string]$EffectiveChangeType = 'breaking', - [string]$CurrentVersion = '1.0.0', - [string]$EffectiveTargetVersion = '2.0.0', - [object[]]$CascadeReasons = @() - ) - [pscustomobject]@{ - Folder = $Folder - Name = $Folder - Source = $Source - AutoUpgraded = $AutoUpgraded - PinHonoredAgainstCascade = $PinHonoredAgainstCascade - EffectiveChangeType = $EffectiveChangeType - CurrentVersion = $CurrentVersion - EffectiveTargetVersion = $EffectiveTargetVersion - CascadeReasons = $CascadeReasons - } - } - } - - BeforeEach { - # Capture every Write-Host call into a buffer so we can grep the - # rendered plan for the footer lines. - $script:Captured = New-Object 'System.Collections.Generic.List[string]' - Mock -CommandName Write-Host -MockWith { - param($Object) - if ($null -ne $Object) { $script:Captured.Add([string]$Object) } - } -ModuleName $null - } - - It 'prints the always-on cascade-upgrade notice line' { - $plan = @{ 'pkg' = New-PlanEntryForFooterTest -Folder 'pkg' } - Show-ReleasePlan -ResolvedReleaseSet $plan - - ($script:Captured -join "`n") | Should -Match 'user-provided change types may be automatically upgraded' - } - - It 'prints the explicit-pin-rejection clarification with -Force override hint' { - $plan = @{ 'pkg' = New-PlanEntryForFooterTest -Folder 'pkg' } - Show-ReleasePlan -ResolvedReleaseSet $plan - - ($script:Captured -join "`n") | Should -Match 'If an explicit version number is specified.*the release plan is rejected.*-Force' - } - - It 'omits the auto-upgraded line when no user entry was strengthened' { - $plan = @{ 'pkg' = New-PlanEntryForFooterTest -Folder 'pkg' -AutoUpgraded $false } - Show-ReleasePlan -ResolvedReleaseSet $plan - - ($script:Captured -join "`n") | Should -Not -Match "tagged 'auto-upgraded by cascade'" - } - - It 'includes the auto-upgraded line when at least one user entry was strengthened' { - $plan = @{ - 'a' = New-PlanEntryForFooterTest -Folder 'a' -AutoUpgraded $false - 'b' = New-PlanEntryForFooterTest -Folder 'b' -AutoUpgraded $true - } - Show-ReleasePlan -ResolvedReleaseSet $plan - - ($script:Captured -join "`n") | Should -Match "tagged 'auto-upgraded by cascade'" - } - - It "uses singular 'Item above' wording when exactly one entry was auto-upgraded" { - $plan = @{ 'b' = New-PlanEntryForFooterTest -Folder 'b' -AutoUpgraded $true } - Show-ReleasePlan -ResolvedReleaseSet $plan - - $captured = $script:Captured -join "`n" - $captured | Should -Match "Item above tagged 'auto-upgraded by cascade' was upgraded from the user-requested change type\." - $captured | Should -Not -Match "Items above tagged 'auto-upgraded by cascade'" - } - - It "uses plural 'Items above' wording when two or more entries were auto-upgraded" { - $plan = @{ - 'a' = New-PlanEntryForFooterTest -Folder 'a' -AutoUpgraded $true - 'b' = New-PlanEntryForFooterTest -Folder 'b' -AutoUpgraded $true - } - Show-ReleasePlan -ResolvedReleaseSet $plan - - $captured = $script:Captured -join "`n" - $captured | Should -Match "Items above tagged 'auto-upgraded by cascade' were upgraded from the user-requested change type\." - $captured | Should -Not -Match "Item above tagged 'auto-upgraded by cascade'" - } - - It 'omits the pin-honored-over-cascade line when no entry was forced' { - $plan = @{ 'pkg' = New-PlanEntryForFooterTest -Folder 'pkg' -PinHonoredAgainstCascade $false } - Show-ReleasePlan -ResolvedReleaseSet $plan - - ($script:Captured -join "`n") | Should -Not -Match "'-Force: pin honored over cascade'" - } - - It 'includes the pin-honored-over-cascade line when at least one entry was forced' { - $plan = @{ - 'a' = New-PlanEntryForFooterTest -Folder 'a' -PinHonoredAgainstCascade $false - 'b' = New-PlanEntryForFooterTest -Folder 'b' -PinHonoredAgainstCascade $true - } - Show-ReleasePlan -ResolvedReleaseSet $plan - - ($script:Captured -join "`n") | Should -Match "'-Force: pin honored over cascade'" - ($script:Captured -join "`n") | Should -Match 'consumers may break' - } - - It "uses singular 'Item above' wording when exactly one entry was forced" { - $plan = @{ 'b' = New-PlanEntryForFooterTest -Folder 'b' -PinHonoredAgainstCascade $true } - Show-ReleasePlan -ResolvedReleaseSet $plan - - $captured = $script:Captured -join "`n" - $captured | Should -Match "Item above tagged '-Force: pin honored over cascade' kept its explicit version pin" - $captured | Should -Not -Match "Items above tagged '-Force: pin honored over cascade' kept their" - } - - It "uses plural 'Items above' wording when two or more entries were forced" { - $plan = @{ - 'a' = New-PlanEntryForFooterTest -Folder 'a' -PinHonoredAgainstCascade $true - 'b' = New-PlanEntryForFooterTest -Folder 'b' -PinHonoredAgainstCascade $true - } - Show-ReleasePlan -ResolvedReleaseSet $plan - - $captured = $script:Captured -join "`n" - $captured | Should -Match "Items above tagged '-Force: pin honored over cascade' kept their explicit version pin" - $captured | Should -Not -Match "Item above tagged '-Force: pin honored over cascade' kept its" - } - - It "tags a forced entry's per-package line with '-Force: pin honored over cascade'" { - $plan = @{ 'b' = New-PlanEntryForFooterTest -Folder 'b' -PinHonoredAgainstCascade $true } - Show-ReleasePlan -ResolvedReleaseSet $plan - - # Per-package line, not the footer: - ($script:Captured -join "`n") | Should -Match '• b:.*-Force: pin honored over cascade' - } - - It 'omits the footer entirely when the plan is empty (just the placeholder line)' { - Show-ReleasePlan -ResolvedReleaseSet @{} - - ($script:Captured -join "`n") | Should -Not -Match 'may be automatically upgraded' - ($script:Captured -join "`n") | Should -Match 'Release plan: \(empty\)' - } -} - -# --------------------------------------------------------------------------- -# Show-ReleaseSummary (released-packages list ordering) -# --------------------------------------------------------------------------- - -Describe 'Show-ReleaseSummary' { - - BeforeEach { - # Capture every Write-Host call so we can assert on the rendered order. - $script:Captured = New-Object 'System.Collections.Generic.List[string]' - Mock -CommandName Write-Host -MockWith { - param($Object) - if ($null -ne $Object) { $script:Captured.Add([string]$Object) } - } -ModuleName $null - } - - It 'prints released packages in alphabetical order regardless of input order' { - # Inputs deliberately out of order (and not just reverse — interleaved). - $releases = @( - [pscustomobject]@{ Package = 'zeta'; OldVersion = '1.0.0'; NewVersion = '1.0.1' } - [pscustomobject]@{ Package = 'alpha'; OldVersion = '1.0.0'; NewVersion = '2.0.0' } - [pscustomobject]@{ Package = 'middle'; OldVersion = '0.4.1'; NewVersion = '0.4.2' } - [pscustomobject]@{ Package = 'bravo'; OldVersion = '0.1.0'; NewVersion = '0.1.1' } - ) - - Show-ReleaseSummary -releases $releases - - # Filter to the package lines, in render order, and assert alphabetical. - $pkgLines = @($script:Captured | Where-Object { $_ -match '^\s*-\s+' }) - $pkgLines.Count | Should -Be 4 - $pkgLines[0] | Should -Match '^\s*-\s+alpha:' - $pkgLines[1] | Should -Match '^\s*-\s+bravo:' - $pkgLines[2] | Should -Match '^\s*-\s+middle:' - $pkgLines[3] | Should -Match '^\s*-\s+zeta:' - } - - It 'handles an empty release list without throwing' { - { Show-ReleaseSummary -releases @() } | Should -Not -Throw - # The header is still emitted (so the user sees the section ran). - ($script:Captured -join "`n") | Should -Match 'Released packages' - } -} diff --git a/scripts/tests/Pester/unit/release-packages/ParseReleaseTokens.Tests.ps1 b/scripts/tests/Pester/unit/release-packages/ParseReleaseTokens.Tests.ps1 deleted file mode 100644 index 34bdde557..000000000 --- a/scripts/tests/Pester/unit/release-packages/ParseReleaseTokens.Tests.ps1 +++ /dev/null @@ -1,145 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -BeforeAll { - . (Join-Path $PSScriptRoot '..\..\_common\TestHelpers.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') -} - -Describe 'Parse-ReleaseTokens' { - Context 'change-type keywords' { - It 'parses breaking, nonbreaking, patch (case-insensitive) into canonical kebab-case values' { - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@nonbreaking', 'c@patch', 'd@BREAKING', 'e@NonBreaking') - - $parsed.Count | Should -Be 5 - - $parsed[0].Name | Should -Be 'a' - $parsed[0].RequestedChangeType | Should -Be 'breaking' - $parsed[0].RequestedTargetVersion | Should -BeNullOrEmpty - $parsed[0].RawToken | Should -Be 'a@breaking' - - $parsed[1].RequestedChangeType | Should -Be 'non-breaking' - $parsed[2].RequestedChangeType | Should -Be 'patch' - $parsed[3].RequestedChangeType | Should -Be 'breaking' - $parsed[4].RequestedChangeType | Should -Be 'non-breaking' - } - - It 'preserves the original case of the package name' { - $parsed = Parse-ReleaseTokens -Tokens @('MixedCase_Name@patch') - $parsed[0].Name | Should -Be 'MixedCase_Name' - } - - It 'records the raw token verbatim including whitespace before trim' { - $parsed = Parse-ReleaseTokens -Tokens @(' spacey@patch ') - $parsed[0].Name | Should -Be 'spacey' - $parsed[0].RawToken | Should -Be ' spacey@patch ' - } - } - - Context 'explicit version pins' { - It 'treats 1.0.0 as an ordinary explicit pin (no special graduation handling)' { - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.0.0') - $parsed[0].RequestedChangeType | Should -BeNullOrEmpty - $parsed[0].RequestedTargetVersion | Should -Be '1.0.0' - } - - It 'accepts an arbitrary semver pin and leaves RequestedChangeType null' { - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.2.3') - $parsed[0].RequestedChangeType | Should -BeNullOrEmpty - $parsed[0].RequestedTargetVersion | Should -Be '1.2.3' - } - - It 'accepts large semver components' { - $parsed = Parse-ReleaseTokens -Tokens @('pkg@42.987.65') - $parsed[0].RequestedTargetVersion | Should -Be '42.987.65' - } - - It 'accepts SemVer 2.0 pre-release identifiers' { - $parsed = Parse-ReleaseTokens -Tokens @( - 'pkg1@1.2.3-pre01', - 'pkg2@1.0.0-rc.1', - 'pkg3@1.0.0-alpha.beta', - 'pkg4@0.1.0-beta+meta' - ) - $parsed[0].RequestedTargetVersion | Should -Be '1.2.3-pre01' - $parsed[1].RequestedTargetVersion | Should -Be '1.0.0-rc.1' - $parsed[2].RequestedTargetVersion | Should -Be '1.0.0-alpha.beta' - $parsed[3].RequestedTargetVersion | Should -Be '0.1.0-beta+meta' - } - - It 'accepts SemVer 2.0 build metadata without pre-release' { - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.0.0+exp.sha.5') - $parsed[0].RequestedTargetVersion | Should -Be '1.0.0+exp.sha.5' - } - } - - Context 'duplicate rejection' { - It 'rejects duplicate names (case-insensitive)' { - { Parse-ReleaseTokens -Tokens @('foo@breaking', 'Foo@patch') } | - Should -Throw -ExpectedMessage "*Duplicate package name 'Foo'*" - } - } - - Context 'malformed tokens' { - It 'rejects an empty -Packages list' { - { Parse-ReleaseTokens -Tokens @() } | Should -Throw -ExpectedMessage '*No packages to release*' - } - - It 'rejects a null -Packages list' { - { Parse-ReleaseTokens -Tokens $null } | Should -Throw -ExpectedMessage '*No packages to release*' - } - - It 'rejects an empty / whitespace-only token' { - { Parse-ReleaseTokens -Tokens @('') } | Should -Throw -ExpectedMessage '*empty or whitespace-only token*' - { Parse-ReleaseTokens -Tokens @(' ') }| Should -Throw -ExpectedMessage '*empty or whitespace-only token*' - } - - It 'rejects tokens missing the @ separator' { - { Parse-ReleaseTokens -Tokens @('pkg-breaking') } | Should -Throw -ExpectedMessage "*Malformed package token 'pkg-breaking'*" - } - - It 'rejects tokens starting with @' { - { Parse-ReleaseTokens -Tokens @('@breaking') } | Should -Throw -ExpectedMessage "*Malformed package token '@breaking'*" - } - - It 'rejects tokens ending with @' { - { Parse-ReleaseTokens -Tokens @('pkg@') } | Should -Throw -ExpectedMessage "*Malformed package token 'pkg@'*" - } - - It 'rejects tokens with more than one @' { - { Parse-ReleaseTokens -Tokens @('pkg@1.0.0@extra') } | Should -Throw -ExpectedMessage "*Malformed package token 'pkg@1.0.0@extra'*" - } - - It 'rejects invalid change keywords' { - { Parse-ReleaseTokens -Tokens @('pkg@major') } | Should -Throw -ExpectedMessage "*Invalid change specifier 'major'*" - { Parse-ReleaseTokens -Tokens @('pkg@feature') } | Should -Throw -ExpectedMessage "*Invalid change specifier 'feature'*" - { Parse-ReleaseTokens -Tokens @('pkg@1.0') } | Should -Throw -ExpectedMessage "*Invalid change specifier '1.0'*" - { Parse-ReleaseTokens -Tokens @('pkg@1') } | Should -Throw -ExpectedMessage "*Invalid change specifier '1'*" - { Parse-ReleaseTokens -Tokens @('pkg@1.2.3.4') } | Should -Throw -ExpectedMessage "*Invalid change specifier '1.2.3.4'*" - { Parse-ReleaseTokens -Tokens @('pkg@v1.2.3') } | Should -Throw -ExpectedMessage "*Invalid change specifier 'v1.2.3'*" - } - - It 'rejects 1- and 2-component versions with the three-component error message' { - # The error message must explicitly mention the three-component - # requirement so users typing 'foo@1' / 'foo@1.2' know how to fix it. - { Parse-ReleaseTokens -Tokens @('pkg@1') } | Should -Throw -ExpectedMessage '*three components*' - { Parse-ReleaseTokens -Tokens @('pkg@1.2') } | Should -Throw -ExpectedMessage '*three components*' - } - - It 'rejects leading-zero components (per SemVer 2.0)' { - { Parse-ReleaseTokens -Tokens @('pkg@01.2.3') } | Should -Throw -ExpectedMessage "*Invalid change specifier '01.2.3'*" - { Parse-ReleaseTokens -Tokens @('pkg@1.02.3') } | Should -Throw -ExpectedMessage "*Invalid change specifier '1.02.3'*" - } - - It 'rejects malformed pre-release suffixes' { - { Parse-ReleaseTokens -Tokens @('pkg@1.2.3-') } | Should -Throw -ExpectedMessage "*Invalid change specifier '1.2.3-'*" - { Parse-ReleaseTokens -Tokens @('pkg@1.2.3-01') } | Should -Throw -ExpectedMessage "*Invalid change specifier '1.2.3-01'*" # leading-zero numeric identifier - } - - It 'rejects invalid package names' { - { Parse-ReleaseTokens -Tokens @('-bad@patch') } | Should -Throw -ExpectedMessage "*Invalid package name '-bad'*" - { Parse-ReleaseTokens -Tokens @('bad-@patch') } | Should -Throw -ExpectedMessage "*Invalid package name 'bad-'*" - { Parse-ReleaseTokens -Tokens @('has space@patch') } | Should -Throw -ExpectedMessage "*Invalid package name 'has space'*" - } - } -} diff --git a/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 b/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 deleted file mode 100644 index 7bd67ccc8..000000000 --- a/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 +++ /dev/null @@ -1,1165 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -BeforeAll { - . (Join-Path $PSScriptRoot '..\..\_common\TestHelpers.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') - - # Helper that builds a baseline package record. Underscore-only cargo - # names by default so the test stays focused on the cascade/resolve logic - # rather than name normalization. - # - # AllowedExternalTypes defaults to @() -- "this crate's public API names - # nothing foreign" -- and NOT to $null. $null is the fail-closed branch - # (absent metadata => assume exposure), so defaulting to it would make - # every package in every baseline expose every dependency for free. That - # masks the signal under test: a cascade assertion would pass on the - # fallback even when the behaviour it is meant to pin is broken. @() is - # inert, so a test that needs exposure has to ask for it -- either with a - # real allowlist entry or by passing $null explicitly. - function New-BaselinePackage { - param( - [string] $Folder, - [string] $Name = $null, - [string] $Version = '0.1.0', - [string[]] $Deps = @(), - [bool] $Published = $true, - [bool] $IsProcMacroOnly = $false, - [hashtable] $DepAliases = @{}, - # The crate's rustdoc name -- its [lib] name, which defaults to the - # normalized package name. Allowlists of INDIRECT dependents are - # rooted at this, never at a rename alias: a rename only exists on - # an edge the renaming crate declares, and an indirect dependent - # declares no such edge. - [string] $CrateRoot = $null, - [AllowNull()][string[]] $AllowedExternalTypes = @() - ) - if ([string]::IsNullOrEmpty($Name)) { $Name = $Folder } - if ([string]::IsNullOrEmpty($CrateRoot)) { $CrateRoot = $Name.Replace('-', '_') } - return [pscustomobject]@{ - Folder = $Folder - Name = $Name - Version = $Version - Published = $Published - Deps = $Deps - DepAliases = $DepAliases - CrateRoot = $CrateRoot - IsProcMacroOnly = $IsProcMacroOnly - AllowedExternalTypes = $AllowedExternalTypes - } - } - - # Builds a stub cargo-semver-checks classifier from a folder -> change-type - # map. Unmapped folders return 'none' (no constraint). Lets the cascade / - # self-floor logic be tested deterministically without invoking the real - # tool. In production the classifier is $script:DefaultSemverClassifier, which - # calls Get-CrateRequiredChangeType (a cached cargo-semver-checks wrapper). - function New-StubClassifier { - param([hashtable]$Map = @{}) - return { - param([string]$Folder, [string]$CargoName) - $t = $Map[$Folder] - if ($t) { return $t } - return 'none' - }.GetNewClosure() - } - - # Linear baseline: a → b → c → d (each depends on the previous). - function New-LinearBaseline { - return @( - (New-BaselinePackage -Folder 'a' -Version '0.1.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '0.1.0' -Deps @('a')) - (New-BaselinePackage -Folder 'c' -Version '0.1.0' -Deps @('b')) - (New-BaselinePackage -Folder 'd' -Version '0.1.0' -Deps @('c')) - ) - } -} - -Describe 'Get-TransitivePublishedDependentsFromBaseline' { - It 'returns all transitive published dependents in a linear chain' { - $baseline = New-LinearBaseline - $result = Get-TransitivePublishedDependentsFromBaseline -Baseline $baseline -TargetCargoName 'a' - $result | Should -Be @('b', 'c', 'd') - } - - It 'excludes the target itself' { - $baseline = New-LinearBaseline - $result = Get-TransitivePublishedDependentsFromBaseline -Baseline $baseline -TargetCargoName 'b' - $result | Should -Not -Contain 'b' - $result | Should -Be @('c', 'd') - } - - It 'traverses through unpublished packages but does not include them in the result' { - # a -> b(unpublished) -> c - $baseline = @( - (New-BaselinePackage -Folder 'a' -Deps @()) - (New-BaselinePackage -Folder 'b' -Deps @('a') -Published $false) - (New-BaselinePackage -Folder 'c' -Deps @('b')) - ) - $result = Get-TransitivePublishedDependentsFromBaseline -Baseline $baseline -TargetCargoName 'a' - $result | Should -Not -Contain 'b' - $result | Should -Contain 'c' - } - - It 'returns an empty result when no package depends on the target' { - $baseline = @( - (New-BaselinePackage -Folder 'a' -Deps @()) - (New-BaselinePackage -Folder 'b' -Deps @()) - ) - $result = @(Get-TransitivePublishedDependentsFromBaseline -Baseline $baseline -TargetCargoName 'a') - $result.Count | Should -Be 0 - } - - It 'returns an empty result for an empty baseline' { - $result = @(Get-TransitivePublishedDependentsFromBaseline -Baseline @() -TargetCargoName 'a') - $result.Count | Should -Be 0 - } -} - -Describe 'Get-DirectPublishedDependentsFromBaseline' { - It 'returns only immediate published consumers' { - # a -> b -> c; private also directly consumes a but is not published. - $baseline = @( - (New-BaselinePackage -Folder 'a') - (New-BaselinePackage -Folder 'b' -Deps @('a')) - (New-BaselinePackage -Folder 'c' -Deps @('b')) - (New-BaselinePackage -Folder 'private' -Deps @('a') -Published $false) - ) - - $result = Get-DirectPublishedDependentsFromBaseline -Baseline $baseline -TargetCargoName 'a' - - $result | Should -Be @('b') - } -} - -Describe 'Resolve-ReleaseSet' { - Context 'single user-source entry without dependents' { - It 'returns a single user-source entry with the right effective state (0.x non-breaking -> 0.y.(z+1))' { - # 0.x.y SemVer: non-breaking is numerically the same as patch - # (0.y.(z+1)). Get-NextVersion handles this; we just assert the - # surfaced semantics here. - $baseline = @((New-BaselinePackage -Folder 'standalone' -Version '0.4.1')) - $parsed = Parse-ReleaseTokens -Tokens @('standalone@nonbreaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - - $resolved.Count | Should -Be 1 - $resolved[0].Folder | Should -Be 'standalone' - $resolved[0].Source | Should -Be 'user' - $resolved[0].EffectiveChangeType | Should -Be 'non-breaking' - $resolved[0].EffectiveTargetVersion | Should -Be '0.4.2' - $resolved[0].AutoUpgraded | Should -BeFalse - $resolved[0].CascadeReasons.Count | Should -Be 0 - $resolved[0].RawToken | Should -Be 'standalone@nonbreaking' - } - - It 'computes EffectiveTargetVersion for a 0.x breaking change as 0.(y+1).0' { - $baseline = @((New-BaselinePackage -Folder 'standalone' -Version '0.4.1')) - $parsed = Parse-ReleaseTokens -Tokens @('standalone@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $resolved[0].EffectiveTargetVersion | Should -Be '0.5.0' - } - - It 'computes EffectiveTargetVersion using Get-NextVersion on a 1.x package' { - $baseline = @((New-BaselinePackage -Folder 'pkg' -Version '1.4.2')) - $parsed = Parse-ReleaseTokens -Tokens @('pkg@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $resolved[0].EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'marks a user-selected proc-macro-only package for manual review without invoking the classifier' { - $baseline = @( - (New-BaselinePackage -Folder 'macros' -Version '1.0.0' -IsProcMacroOnly $true) - ) - $classifier = { - throw 'The automated classifier must not run for proc-macro-only packages.' - } - $parsed = Parse-ReleaseTokens -Tokens @('macros@patch') - - $resolved = Resolve-ReleaseSet ` - -ParsedTokens $parsed ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $classifier - - $resolved[0].EffectiveChangeType | Should -Be 'patch' - $resolved[0].RequiresManualSemverReview | Should -BeTrue - $resolved[0].IsProcMacroOnly | Should -BeTrue - } - } - - Describe 'Get-ManualSemverReviewFindings: breaking review propagation' { - BeforeAll { - function script:New-ProcMacroReviewBaseline { - return @( - (New-BaselinePackage -Folder 'macros' -Name 'macros' -Version '1.0.0' -IsProcMacroOnly $true) - (New-BaselinePackage -Folder 'facade' -Name 'facade' -Version '1.0.0' -Deps @('macros')) - (New-BaselinePackage -Folder 'app' -Name 'app' -Version '1.0.0' -Deps @('facade')) - ) - } - - function script:Resolve-ToHash { - param( - [object[]]$Baseline, - [string[]]$Tokens - ) - $parsed = Parse-ReleaseTokens -Tokens $Tokens - $entries = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $Baseline - $result = @{} - foreach ($entry in $entries) { $result[$entry.Folder] = $entry } - return $result - } - } - - It 'does not advance beyond an unreviewed proc macro' { - $baseline = New-ProcMacroReviewBaseline - $resolved = Resolve-ToHash -Baseline $baseline -Tokens @('macros@breaking') - $reviewed = [System.Collections.Generic.HashSet[string]]::new() - - $findings = @(Get-ManualSemverReviewFindings ` - -ResolvedReleaseSet $resolved ` - -WorkspaceBaseline $baseline ` - -ReviewedManualSemver $reviewed) - - $findings.Folder | Should -Be @('macros') - } - - It 'surfaces only the direct consumer after the proc macro is reviewed as breaking' { - $baseline = New-ProcMacroReviewBaseline - $resolved = Resolve-ToHash -Baseline $baseline -Tokens @('macros@breaking') - $reviewed = [System.Collections.Generic.HashSet[string]]::new() - [void]$reviewed.Add('macros') - - $findings = @(Get-ManualSemverReviewFindings ` - -ResolvedReleaseSet $resolved ` - -WorkspaceBaseline $baseline ` - -ReviewedManualSemver $reviewed) - $facade = $findings | Where-Object { $_.Folder -eq 'facade' } - - $facade | Should -Not -BeNullOrEmpty - $facade.ManualSemverReviewKind | Should -Be 'proc-macro-dependent' - $facade.ManualSemverReviewSources | Should -Be @('macros') - $findings.Folder | Should -Not -Contain 'app' - } - - It 'stops when the reviewed proc macro is non-breaking' { - $baseline = New-ProcMacroReviewBaseline - $resolved = Resolve-ToHash -Baseline $baseline -Tokens @('macros@patch') - $reviewed = [System.Collections.Generic.HashSet[string]]::new() - [void]$reviewed.Add('macros') - - $findings = @(Get-ManualSemverReviewFindings ` - -ResolvedReleaseSet $resolved ` - -WorkspaceBaseline $baseline ` - -ReviewedManualSemver $reviewed) - - $findings.Folder | Should -Be @('macros') - } - - It 'advances to the next hop only after the direct consumer is reviewed as breaking' { - $baseline = New-ProcMacroReviewBaseline - $resolved = Resolve-ToHash -Baseline $baseline -Tokens @('macros@breaking', 'facade@breaking') - $reviewed = [System.Collections.Generic.HashSet[string]]::new() - [void]$reviewed.Add('macros') - [void]$reviewed.Add('facade') - - $findings = @(Get-ManualSemverReviewFindings ` - -ResolvedReleaseSet $resolved ` - -WorkspaceBaseline $baseline ` - -ReviewedManualSemver $reviewed) - $app = $findings | Where-Object { $_.Folder -eq 'app' } - - $app | Should -Not -BeNullOrEmpty - $app.ManualSemverReviewSources | Should -Be @('facade') - } - - It 'does not advance when the direct consumer is reviewed below breaking' { - $baseline = New-ProcMacroReviewBaseline - $resolved = Resolve-ToHash -Baseline $baseline -Tokens @('macros@breaking', 'facade@patch') - $reviewed = [System.Collections.Generic.HashSet[string]]::new() - [void]$reviewed.Add('macros') - [void]$reviewed.Add('facade') - - $findings = @(Get-ManualSemverReviewFindings ` - -ResolvedReleaseSet $resolved ` - -WorkspaceBaseline $baseline ` - -ReviewedManualSemver $reviewed) - - $findings.Folder | Should -Not -Contain 'app' - } - - It 'follows the actual forced pin instead of a stronger internal severity tag' { - $baseline = New-ProcMacroReviewBaseline - $parsed = Parse-ReleaseTokens -Tokens @('macros@breaking', 'facade@1.1.0') - $classifier = New-StubClassifier @{ facade = 'breaking' } - $entries = Resolve-ReleaseSet ` - -ParsedTokens $parsed ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $classifier ` - -Force - $resolved = @{} - foreach ($entry in $entries) { $resolved[$entry.Folder] = $entry } - $reviewed = [System.Collections.Generic.HashSet[string]]::new() - [void]$reviewed.Add('macros') - [void]$reviewed.Add('facade') - - # -Force retains the non-breaking 1.1.0 pin while the internal tag stays - # breaking so cascade bookkeeping remains conservative. - $resolved['facade'].EffectiveChangeType | Should -Be 'breaking' - $resolved['facade'].EffectiveTargetVersion | Should -Be '1.1.0' - - $findings = @(Get-ManualSemverReviewFindings ` - -ResolvedReleaseSet $resolved ` - -WorkspaceBaseline $baseline ` - -ReviewedManualSemver $reviewed) - - $findings.Folder | Should -Contain 'facade' - $findings.Folder | Should -Not -Contain 'app' - } - } - - Context 'explicit version pins' { - It 'accepts a strictly-greater pin and derives EffectiveChangeType from the transition' { - $baseline = @((New-BaselinePackage -Folder 'pkg' -Version '1.2.3')) - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.3.0') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $resolved[0].EffectiveTargetVersion | Should -Be '1.3.0' - $resolved[0].EffectiveChangeType | Should -Be 'non-breaking' - $resolved[0].RequestedTargetVersion | Should -Be '1.3.0' - $resolved[0].RequestedChangeType | Should -BeNullOrEmpty - } - - It 'rejects a pin equal to the current version' { - $baseline = @((New-BaselinePackage -Folder 'pkg' -Version '1.2.3')) - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.2.3') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline } | - Should -Throw -ExpectedMessage "*already at v1.2.3*" - } - - It 'rejects a pin lower than the current version' { - $baseline = @((New-BaselinePackage -Folder 'pkg' -Version '1.2.3')) - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.2.0') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline } | - Should -Throw -ExpectedMessage "*already at v1.2.3*" - } - } - - Context 'explicit version pin to 1.0.0' { - It 'accepts an explicit 1.0.0 pin on a 0.x.y package' { - $baseline = @((New-BaselinePackage -Folder 'pkg' -Version '0.4.1')) - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.0.0') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $resolved[0].EffectiveTargetVersion | Should -Be '1.0.0' - $resolved[0].EffectiveChangeType | Should -Be 'breaking' - } - - It 'rejects an explicit 1.0.0 pin when the package is already at 1.0.0 (pin-validation: pin must be > current)' { - $baseline = @((New-BaselinePackage -Folder 'pkg' -Version '1.0.0')) - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.0.0') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline } | - Should -Throw -ExpectedMessage "*'pkg'*already at v1.0.0*" - } - - It 'rejects an explicit 1.0.0 pin when the package is already at a higher 1.x version' { - $baseline = @((New-BaselinePackage -Folder 'pkg' -Version '1.2.0')) - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.0.0') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline } | - Should -Throw -ExpectedMessage "*'pkg'*already at v1.2.0*" - } - } - - Context 'unknown / unpublished packages' { - It 'rejects a token for a package that is not in the workspace' { - $baseline = @((New-BaselinePackage -Folder 'real' -Version '0.1.0')) - $parsed = Parse-ReleaseTokens -Tokens @('imaginary@patch') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline } | - Should -Throw -ExpectedMessage "*'imaginary'*not part of the workspace*" - } - - It 'rejects a token for an unpublished package' { - $baseline = @((New-BaselinePackage -Folder 'internal' -Version '0.1.0' -Published $false)) - $parsed = Parse-ReleaseTokens -Tokens @('internal@patch') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline } | - Should -Throw -ExpectedMessage "*'internal'*publish = false*" - } - } - - Context 'cargo name vs folder name lookup' { - It 'finds a package by its underscore-normalized cargo name when the token uses hyphens' { - $baseline = @((New-BaselinePackage -Folder 'http_extensions' -Name 'http-extensions' -Version '0.4.1')) - $parsed = Parse-ReleaseTokens -Tokens @('http-extensions@nonbreaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $resolved.Count | Should -Be 1 - $resolved[0].Folder | Should -Be 'http_extensions' - $resolved[0].Name | Should -Be 'http-extensions' - } - } - - Context 'cascade to transitive dependents' { - It 'pulls in direct & transitive published dependents as cascade-source entries' { - $baseline = New-LinearBaseline - $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $resolved.Count | Should -Be 4 - - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - - $byFolder['a'].Source | Should -Be 'user' - $byFolder['b'].Source | Should -Be 'cascade' - $byFolder['c'].Source | Should -Be 'cascade' - $byFolder['d'].Source | Should -Be 'cascade' - - # Each cascade-source entry has a single reason pointing at the user target. - $byFolder['b'].CascadeReasons.Count | Should -Be 1 - $byFolder['b'].CascadeReasons[0].Target | Should -Be 'a' - $byFolder['c'].CascadeReasons[0].Target | Should -Be 'a' - $byFolder['d'].CascadeReasons[0].Target | Should -Be 'a' - } - - It 'classifies cascade dependents via cargo-semver-checks: API-broken dependent is breaking, unaffected dependent is patch' { - # a released breaking. b's own public API broke (semver-checks: - # breaking, e.g. it re-exports a changed type); c's did not - # (semver-checks: none) but c must still re-release to pick up new a - # => floored to patch. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('a') ` - -AllowedExternalTypes @()) - ) - $classifier = New-StubClassifier @{ b = 'breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier - - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - - $byFolder['a'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['a'].EffectiveTargetVersion | Should -Be '2.0.0' - - $byFolder['b'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['b'].EffectiveTargetVersion | Should -Be '2.0.0' - $byFolder['b'].CascadeReasons[0].Breaking | Should -BeTrue - - $byFolder['c'].EffectiveChangeType | Should -Be 'patch' - $byFolder['c'].EffectiveTargetVersion | Should -Be '1.0.1' - $byFolder['c'].CascadeReasons[0].Breaking | Should -BeFalse - } - - It 'derives each cascade dependent''s change type from its own semver-checks verdict, not the target''s' { - # a -> b -> c, releasing a as patch. b's own API is non-breaking; c's - # is unaffected. The dependent severities come from semver-checks on - # each dependent, independent of a's (patch) change type. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') ` - -AllowedExternalTypes @()) - ) - $classifier = New-StubClassifier @{ b = 'non-breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@patch') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - $byFolder['b'].EffectiveChangeType | Should -Be 'non-breaking' - $byFolder['c'].EffectiveChangeType | Should -Be 'patch' - } - - It 'raises an unchanged dependent when it exposes an incompatibly bumped dependency' { - $baseline = @( - (New-BaselinePackage -Folder 'bytesbuf' -Version '0.7.0') - (New-BaselinePackage -Folder 'bytesbuf_io' -Version '0.7.0' -Deps @('bytesbuf') ` - -AllowedExternalTypes @('bytesbuf::*')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('bytesbuf@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $byFolder = @{} - foreach ($entry in $resolved) { $byFolder[$entry.Folder] = $entry } - - $byFolder['bytesbuf_io'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['bytesbuf_io'].EffectiveTargetVersion | Should -Be '0.8.0' - $byFolder['bytesbuf_io'].CascadeReasons[0].Breaking | Should -BeTrue - } - - It 'keeps an unchanged dependent at patch when it does not expose the bumped dependency' { - $baseline = @( - (New-BaselinePackage -Folder 'dependency' -Version '1.0.0') - (New-BaselinePackage -Folder 'dependent' -Version '1.0.0' -Deps @('dependency') ` - -AllowedExternalTypes @('other_crate::*')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('dependency@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'patch' - $dependent.EffectiveTargetVersion | Should -Be '1.0.1' - } - - It 'treats missing external-type metadata conservatively' { - # $null is passed explicitly: this test is *about* absent metadata, - # so the signal must come from the arguments and not from a helper - # default. New-BaselinePackage defaults to @() precisely so that no - # other test silently depends on the fail-closed branch. - $baseline = @( - (New-BaselinePackage -Folder 'dependency' -Version '1.0.0') - (New-BaselinePackage -Folder 'dependent' -Version '1.0.0' -Deps @('dependency') ` - -AllowedExternalTypes $null) - ) - $parsed = Parse-ReleaseTokens -Tokens @('dependency@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'breaking' - $dependent.EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'treats wildcard external-type metadata as possible exposure' { - $baseline = @( - (New-BaselinePackage -Folder 'dependency' -Version '1.0.0') - (New-BaselinePackage -Folder 'dependent' -Version '1.0.0' -Deps @('dependency') ` - -AllowedExternalTypes @('*')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('dependency@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'breaking' - $dependent.EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'propagates exposed incompatible dependency versions through multiple direct edges' { - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0') - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a') ` - -AllowedExternalTypes @('a::*')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') ` - -AllowedExternalTypes @('b::*')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $byFolder = @{} - foreach ($entry in $resolved) { $byFolder[$entry.Folder] = $entry } - - $byFolder['b'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['c'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['c'].CascadeReasons.Target | Should -Contain 'b' - } - - It 'cascades through an allowlist entry rooted at a renamed dependency alias' { - # `dependent` declares `dependency` under `package = "..."` as - # `aliased_dep`, so its allowlist can only name the alias. Matching - # on the real package name alone would miss it and ship the break. - $baseline = @( - (New-BaselinePackage -Folder 'dependency' -Version '1.0.0') - (New-BaselinePackage -Folder 'dependent' -Version '1.0.0' -Deps @('dependency') ` - -DepAliases @{ dependency = @('aliased_dep') } ` - -AllowedExternalTypes @('aliased_dep::Handle')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('dependency@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'breaking' - $dependent.EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'does not cascade when the alias in the allowlist belongs to a different dependency' { - $baseline = @( - (New-BaselinePackage -Folder 'dependency' -Version '1.0.0') - (New-BaselinePackage -Folder 'dependent' -Version '1.0.0' -Deps @('dependency') ` - -DepAliases @{ other_crate = @('aliased_dep') } ` - -AllowedExternalTypes @('aliased_dep::Handle')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('dependency@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline - $dependent = $resolved | Where-Object { $_.Folder -eq 'dependent' } - - $dependent.EffectiveChangeType | Should -Be 'patch' - $dependent.EffectiveTargetVersion | Should -Be '1.0.1' - } - - It 'uses a self-floor breaking verdict before cascading exposed dependency versions' { - $baseline = @( - (New-BaselinePackage -Folder 'dependency' -Version '1.0.0') - (New-BaselinePackage -Folder 'dependent' -Version '1.0.0' -Deps @('dependency') ` - -AllowedExternalTypes @('dependency::*')) - ) - $classifier = New-StubClassifier @{ dependency = 'breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('dependency@patch') - - $resolved = Resolve-ReleaseSet ` - -ParsedTokens $parsed ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $classifier - $byFolder = @{} - foreach ($entry in $resolved) { $byFolder[$entry.Folder] = $entry } - - $byFolder['dependency'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['dependent'].EffectiveChangeType | Should -Be 'breaking' - } - - It 'adds a proc-macro-only cascade dependent at the mechanical patch floor for manual review' { - # consumer -> macros -> implementation. Releasing implementation - # pulls in both dependents. The proc-macro itself cannot be - # cargo-semver-checked, while the ordinary consumer still can. - $baseline = @( - (New-BaselinePackage -Folder 'implementation' -Version '1.0.0') - (New-BaselinePackage -Folder 'macros' -Version '1.0.0' -Deps @('implementation') -IsProcMacroOnly $true) - (New-BaselinePackage -Folder 'consumer' -Version '1.0.0' -Deps @('macros')) - ) - $calls = [System.Collections.Generic.List[string]]::new() - $classifier = { - param([string]$Folder, [string]$CargoName) - $calls.Add($Folder) - return 'none' - }.GetNewClosure() - $parsed = Parse-ReleaseTokens -Tokens @('implementation@patch') - - $resolved = Resolve-ReleaseSet ` - -ParsedTokens $parsed ` - -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $classifier - $byFolder = @{} - foreach ($entry in $resolved) { $byFolder[$entry.Folder] = $entry } - - $byFolder['macros'].Source | Should -Be 'cascade' - $byFolder['macros'].EffectiveChangeType | Should -Be 'patch' - $byFolder['macros'].RequiresManualSemverReview | Should -BeTrue - $calls | Should -Not -Contain 'macros' - $calls | Should -Contain 'implementation' - $calls | Should -Contain 'consumer' - } - } - - Context 'cascade auto-upgrade of user-source entries' { - It 'auto-upgrades a user-source patch to non-breaking when its own semver-checks verdict requires it (and sets AutoUpgraded)' { - # b requested as patch, but semver-checks says b's own API is - # non-breaking, so its change type is floored up and AutoUpgraded set. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'non-breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking', 'b@patch') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - $byFolder['b'].Source | Should -Be 'user' - $byFolder['b'].AutoUpgraded | Should -BeTrue - $byFolder['b'].RequestedChangeType | Should -Be 'patch' - $byFolder['b'].EffectiveChangeType | Should -Be 'non-breaking' - $byFolder['b'].EffectiveTargetVersion | Should -Be '1.1.0' - } - - It 'does NOT mark AutoUpgraded when the user requested the same change type semver-checks asks for' { - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'non-breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking', 'b@nonbreaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - $byFolder['b'].AutoUpgraded | Should -BeFalse - } - - It 'does NOT downgrade the user-supplied change type when semver-checks asks for a weaker change' { - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'patch' } - $parsed = Parse-ReleaseTokens -Tokens @('a@patch', 'b@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - $byFolder['b'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['b'].EffectiveTargetVersion | Should -Be '2.0.0' - $byFolder['b'].AutoUpgraded | Should -BeFalse - } - } - - Context 'cascade interaction with explicit version pins' { - It 'keeps the pin when it numerically satisfies the required version' { - # a non-breaking; b's own API non-breaking (required 1.1.0). b pinned - # to 1.5.0 (well above), so the pin is kept. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'non-breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking', 'b@1.5.0') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - $byFolder['b'].EffectiveTargetVersion | Should -Be '1.5.0' - $byFolder['b'].RequestedTargetVersion | Should -Be '1.5.0' - } - - It 'throws when the pin is numerically below the required version' { - # b's own API broke (semver-checks: breaking) => requires 2.0.0, but - # user pinned b at 1.1.0. Resolution must throw. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier } | - Should -Throw -ExpectedMessage "*Cannot release 'b' as v1.1.0*requires*v2.0.0*" - } - - It 'mentions -Force in the rejection error message so the user knows about the override' { - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier } | - Should -Throw -ExpectedMessage '*-Force*' - } - - It '-Force honors the explicit pin verbatim when a higher version is required' { - # b's own API broke => normally requires 2.0.0; user pinned b at - # 1.1.0. With -Force, b stays at 1.1.0 but the change-type tag is - # upgraded to record the unmet requirement. Further exposure - # propagation follows the actual 1.0.0 -> 1.1.0 transition. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier -Force -WarningAction SilentlyContinue - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - $byFolder['b'].EffectiveTargetVersion | Should -Be '1.1.0' - $byFolder['b'].RequestedTargetVersion | Should -Be '1.1.0' - $byFolder['b'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['b'].PinHonoredAgainstCascade | Should -BeTrue - } - - It '-Force does not stop the exposure cascade at the pinned crate' { - # Exposed chain a -> b -> c. a@breaking drives b breaking, but b is - # pinned to 1.1.0 -- numerically compatible from 1.0.0. The pin - # changes b's version, not b's API: b@1.1.0 is still built against - # a@2.0.0 and exposes `a::*`, so its public API names different - # types than b@1.0.0 did, and a consumer of `b = "1.0"` upgrades - # into that silently. c must inherit the break even though b's own - # version transition looks compatible. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0') - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a') ` - -AllowedExternalTypes @('a::*')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') ` - -AllowedExternalTypes @('b::*')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -Force -WarningAction SilentlyContinue - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - - # The pin is still honored verbatim -- -Force writes the number the - # user asked for, and only the propagation decision changes. - $byFolder['b'].EffectiveTargetVersion | Should -Be '1.1.0' - $byFolder['b'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['b'].PinHonoredAgainstCascade | Should -BeTrue - - $byFolder['c'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['c'].EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'resumes the exposure cascade past a pinned crate when the pin is itself breaking' { - # Same chain, but b is pinned to 2.0.0 -- an incompatible - # transition -- so c must inherit the break. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0') - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a') ` - -AllowedExternalTypes @('a::*')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') ` - -AllowedExternalTypes @('b::*')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@2.0.0') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -Force -WarningAction SilentlyContinue - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - - $byFolder['b'].EffectiveTargetVersion | Should -Be '2.0.0' - $byFolder['c'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['c'].EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'does not propagate from a compatible release that no pin suppressed' { - # Boundary guard for the forced-pin clause: propagation is widened - # only by PinHonoredAgainstCascade, not by every entry in the set. - # a releases non-breaking, so b's own release stays compatible and - # nothing was suppressed -- c must remain at its patch floor. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0') - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a') ` - -AllowedExternalTypes @('a::*')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') ` - -AllowedExternalTypes @('b::*')) - ) - $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -WarningAction SilentlyContinue - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - - $byFolder['b'].PinHonoredAgainstCascade | Should -BeFalse - $byFolder['b'].EffectiveChangeType | Should -Be 'patch' - $byFolder['c'].EffectiveChangeType | Should -Be 'patch' - } - - It 'does not propagate from a forced pin that suppressed only a non-breaking requirement' { - # PinHonoredAgainstCascade is set for ANY suppressed requirement, - # not only a breaking one. Here b's own self-check requires - # non-breaking (1.1.0) and the user pins 1.0.1 with -Force, so the - # flag is set while the suppressed requirement was merely additive. - # An additive API change breaks no consumer, so c must stay at its - # patch floor rather than being dragged to a major release. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0') - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a') ` - -AllowedExternalTypes @('a::*')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') ` - -AllowedExternalTypes @('b::*')) - ) - $classifier = { param($folder) if ($folder -eq 'b') { 'non-breaking' } else { 'patch' } } - $parsed = Parse-ReleaseTokens -Tokens @('b@1.0.1') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType $classifier -Force -WarningAction SilentlyContinue - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - - $byFolder['b'].PinHonoredAgainstCascade | Should -BeTrue - $byFolder['b'].EffectiveChangeType | Should -Be 'non-breaking' - $byFolder['b'].EffectiveTargetVersion | Should -Be '1.0.1' - - $byFolder['c'].EffectiveChangeType | Should -Not -Be 'breaking' - } - - It '-Force emits a warning naming the package, the pin, the required minimum, and the sources' { - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') - $warnings = @() - $null = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier -Force -WarningVariable +warnings -WarningAction SilentlyContinue - ($warnings -join "`n") | Should -Match '-Force' - ($warnings -join "`n") | Should -Match "'b'" - ($warnings -join "`n") | Should -Match 'v1\.1\.0' - ($warnings -join "`n") | Should -Match 'v2\.0\.0' - ($warnings -join "`n") | Should -Match 'a' - } - - It '-Force does NOT set PinHonoredAgainstCascade when the pin already satisfies the requirement' { - # a non-breaking; b's own API non-breaking (required 1.1.0); user - # pinned b at 1.5.0 which already satisfies. -Force is a no-op here. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - ) - $classifier = New-StubClassifier @{ b = 'non-breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking', 'b@1.5.0') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier -Force - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - $byFolder['b'].PinHonoredAgainstCascade | Should -BeFalse - $byFolder['b'].EffectiveTargetVersion | Should -Be '1.5.0' - } - - It '-Force does NOT relax the always-fatal "pin not strictly greater than current" check' { - # Pin equal to current version is always rejected, even with -Force, - # because it would be a no-op (or downgrade) regardless of cascade. - $baseline = @((New-BaselinePackage -Folder 'pkg' -Version '1.2.3')) - $parsed = Parse-ReleaseTokens -Tokens @('pkg@1.2.3') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -Force } | - Should -Throw -ExpectedMessage '*strictly greater than the current version*' - } - } - - Context 'diamond dependency with two user-source roots' { - It 'accumulates one cascade reason per dependency into the diamond bottom and strengthens correctly' { - # a, x are roots; c depends on both. c's own API broke (semver-checks: - # breaking) because of x's breaking change. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'x' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('a', 'x')) - ) - $classifier = New-StubClassifier @{ c = 'breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('a@patch', 'x@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - - $byFolder['c'].CascadeReasons.Count | Should -Be 2 - $reasonTargets = @($byFolder['c'].CascadeReasons | ForEach-Object { $_.Target } | Sort-Object) - $reasonTargets | Should -Be @('a', 'x') - - # c's own API broke => c becomes breaking. - $byFolder['c'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['c'].EffectiveTargetVersion | Should -Be '2.0.0' - } - } - - Context 'transitive cascade reason aggregation' { - It 'records reasons for both the direct and indirect target when a middle crate is auto-upgraded' { - # Linear chain a -> b -> c. Tokens `b a` so b iterates first, then a's - # BFS reaches both b and c. b's own API broke (semver-checks: - # breaking) so b is bumped to breaking; c's is unaffected so c stays - # patch under the per-dependent classification. - $baseline = @( - (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') ` - -AllowedExternalTypes @()) - ) - $classifier = New-StubClassifier @{ b = 'breaking' } - $parsed = Parse-ReleaseTokens -Tokens @('b@patch', 'a@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier - - $byFolder = @{} - foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - - $byFolder['b'].EffectiveTargetVersion | Should -Be '2.0.0' - $byFolder['b'].EffectiveChangeType | Should -Be 'breaking' - $byFolder['b'].AutoUpgraded | Should -BeTrue - - $byFolder['c'].EffectiveChangeType | Should -Be 'patch' - $byFolder['c'].EffectiveTargetVersion | Should -Be '1.0.1' - - $cReasonForB = @($byFolder['c'].CascadeReasons | Where-Object { $_.Target -eq 'b' }) - $cReasonForB.Count | Should -Be 1 - # Breaking reflects c's own (patch) change, not b's. - $cReasonForB[0].Breaking | Should -BeFalse - - $cReasonForA = @($byFolder['c'].CascadeReasons | Where-Object { $_.Target -eq 'a' }) - $cReasonForA.Count | Should -Be 1 - } - } -} - -Describe 'Resolve-ReleaseSet exposure cascade over re-exported types' { - # cargo-check-external-types attributes a re-exported type to its DEFINING - # crate. A crate that reaches `defining::T` through an intermediate - # therefore allowlists `defining` while depending only on the intermediate. - # fetch_azure documents exactly this in its own manifest: - # - # # azure_core re-exports its HttpClient trait from this crate; - # # cargo-check-external-types reports re-exports by their defining crate. - # "typespec_client_core::*", - # - # Requiring a direct dependency edge missed every such crate, which is a - # fail-open: a breaking bump of the defining crate shipped as compatible. - - It 'raises an indirect dependent whose allowlist names the defining crate' { - # relay is raised directly because it exposes defining. facade requires - # the indirect defining-crate path: it depends on relay, but its - # allowlist names defining rather than its declared dependency. - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'relay' -Version '1.0.0' -Deps @('defining') ` - -AllowedExternalTypes @('defining::Handle') - New-BaselinePackage -Folder 'facade' -Version '1.0.0' -Deps @('relay') ` - -AllowedExternalTypes @('defining::Handle') - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - $facade = $resolved | Where-Object { $_.Folder -eq 'facade' } - - $facade.EffectiveChangeType | Should -Be 'breaking' - $facade.EffectiveTargetVersion | Should -Be '2.0.0' - } - - It 'records the defining crate as the cascade reason, not the intermediate' { - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'relay' -Version '1.0.0' -Deps @('defining') ` - -AllowedExternalTypes @('unrelated::Thing') - New-BaselinePackage -Folder 'facade' -Version '1.0.0' -Deps @('relay') ` - -AllowedExternalTypes @('defining::Handle') - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - $facade = $resolved | Where-Object { $_.Folder -eq 'facade' } - - $facade.EffectiveChangeType | Should -Be 'breaking' - @($facade.CascadeReasons | Where-Object { $_.Target -eq 'defining' }).Count | - Should -Be 1 -Because 'the break originates at the crate the type is defined in' - } - - It 'raises an indirect dependent even when the intermediate stays compatible' { - # relay explicitly claims to expose nothing, so it correctly stays at - # its patch floor while facade above it must still break. This is the - # unmasked case: with a direct-edge-only scan nothing reaches facade. - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'relay' -Version '1.0.0' -Deps @('defining') ` - -AllowedExternalTypes @() - New-BaselinePackage -Folder 'facade' -Version '1.0.0' -Deps @('relay') ` - -AllowedExternalTypes @('defining::Handle') - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - - ($resolved | Where-Object { $_.Folder -eq 'relay' }).EffectiveChangeType | Should -Be 'patch' - ($resolved | Where-Object { $_.Folder -eq 'facade' }).EffectiveChangeType | Should -Be 'breaking' - } - - It "matches an indirect allowlist entry rooted at the target's [lib] name" { - # facade names def_v1::Handle because that is what rustdoc calls the - # type: the crate root follows defining's [lib] name, not its package - # name. facade cannot learn this from a rename alias -- it has no edge - # to defining to rename -- so the root has to come from the target. - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -CrateRoot 'def_v1' ` - -AllowedExternalTypes @() - New-BaselinePackage -Folder 'relay' -Version '1.0.0' -Deps @('defining') ` - -AllowedExternalTypes @() - New-BaselinePackage -Folder 'facade' -Version '1.0.0' -Deps @('relay') ` - -AllowedExternalTypes @('def_v1::Handle') - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - - ($resolved | Where-Object { $_.Folder -eq 'facade' }).EffectiveChangeType | Should -Be 'breaking' - } - - Context 'the indirect edge demands positive evidence' { - # The direct edge fails closed on "no evidence" because an unknown must - # not ship a break as compatible. Carrying that rule to indirect edges - # would force every transitive dependent that lacks metadata to - # breaking, which is a large and wrong over-cascade. These pin the - # narrower rule. - - It 'does not raise an indirect dependent that declares no allowlist' { - # relay's empty allowlist is a positive claim that it exposes - # nothing, so no type of defining's can reach facade through it. - # facade's absent metadata is not evidence to the contrary. - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'relay' -Version '1.0.0' -Deps @('defining') ` - -AllowedExternalTypes @() - New-BaselinePackage -Folder 'facade' -Version '1.0.0' -Deps @('relay') ` - -AllowedExternalTypes $null - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - $facade = $resolved | Where-Object { $_.Folder -eq 'facade' } - - $facade.EffectiveChangeType | Should -Be 'patch' - $facade.EffectiveTargetVersion | Should -Be '1.0.1' - } - - It 'still fails closed for a DIRECT dependent that declares no allowlist' { - # Same absent metadata, direct edge: must fail closed. - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'relay' -Version '1.0.0' -Deps @('defining') ` - -AllowedExternalTypes $null - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - - ($resolved | Where-Object { $_.Folder -eq 'relay' }).EffectiveChangeType | Should -Be 'breaking' - } - - It 'does not raise an indirect dependent whose allowlist names only the intermediate' { - # facade names relay, not defining. relay itself does not expose - # defining, so facade cannot be holding one of defining's types. - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'relay' -Version '1.0.0' -Deps @('defining') ` - -AllowedExternalTypes @() - New-BaselinePackage -Folder 'facade' -Version '1.0.0' -Deps @('relay') ` - -AllowedExternalTypes @('relay::Adapter') - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - - ($resolved | Where-Object { $_.Folder -eq 'facade' }).EffectiveChangeType | Should -Be 'patch' - } - - It 'does not raise a crate that names the target but cannot reach it' { - # No dependency path at all: an allowlist entry naming a same-named - # crate from elsewhere must not manufacture a cascade edge. - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'stranger' -Version '1.0.0' ` - -AllowedExternalTypes @('defining::Handle') - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - - @($resolved | Where-Object { $_.Folder -eq 'stranger' }).Count | - Should -Be 0 -Because 'stranger is not a dependent of defining at all' - } - } - - It 'reaches an indirect dependent through an unpublished conduit' { - # Unpublished crates are not released themselves but still carry types - # between published ones, so they must not break the reachability walk. - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'internal' -Version '1.0.0' -Deps @('defining') ` - -Published $false -AllowedExternalTypes @() - New-BaselinePackage -Folder 'facade' -Version '1.0.0' -Deps @('internal') ` - -AllowedExternalTypes @('defining::Handle') - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - - ($resolved | Where-Object { $_.Folder -eq 'facade' }).EffectiveChangeType | Should -Be 'breaking' - } - - It 'excludes a proc-macro-only crate from the indirect edge' { - $baseline = @( - New-BaselinePackage -Folder 'defining' -Version '1.0.0' -AllowedExternalTypes @() - New-BaselinePackage -Folder 'relay' -Version '1.0.0' -Deps @('defining') ` - -AllowedExternalTypes @() - New-BaselinePackage -Folder 'macros' -Version '1.0.0' -Deps @('relay') ` - -IsProcMacroOnly $true -AllowedExternalTypes @('defining::Handle') - ) - $parsed = Parse-ReleaseTokens -Tokens @('defining@breaking') - - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline ` - -GetRequiredChangeType (New-StubClassifier) - $macros = $resolved | Where-Object { $_.Folder -eq 'macros' } - - $macros.EffectiveChangeType | Should -Be 'patch' -Because 'a proc-macro crate has no rustdoc API to expose types through' - } -} diff --git a/scripts/tests/Pester/unit/releasing/DirectDependencyChangelogReasons.Tests.ps1 b/scripts/tests/Pester/unit/releasing/DirectDependencyChangelogReasons.Tests.ps1 deleted file mode 100644 index d3af73939..000000000 --- a/scripts/tests/Pester/unit/releasing/DirectDependencyChangelogReasons.Tests.ps1 +++ /dev/null @@ -1,266 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# -# Unit tests for Get-DirectDependencyChangelogReasons (ADO bug 7536096). -# -# A dependent's changelog "Now requires of " bullets must name -# only the DIRECT workspace dependencies declared in that crate's own Cargo.toml -# (normal/build, dev excluded) that are part of THIS release with a changed -# version — each at its NEW version. This is deliberately decoupled from the -# entry's CascadeReasons (which attribute a cascade to its root-cause crate for -# pin-conflict / plan diagnostics). The regression these tests pin: an INDIRECT -# dependent used to get a bullet naming the root-cause crate it does not directly -# depend on. - -BeforeAll { - . (Join-Path $PSScriptRoot '..\..\_common\TestHelpers.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') - - function New-BaselinePackage { - param( - [string] $Folder, - [string] $Name = $null, - [string] $Version = '0.1.0', - [string[]] $Deps = @(), - [bool] $Published = $true - ) - if ([string]::IsNullOrEmpty($Name)) { $Name = $Folder } - return [pscustomobject]@{ - Folder = $Folder - Name = $Name - Version = $Version - Published = $Published - Deps = $Deps - } - } - - function New-ResolvedEntry { - param( - [string] $Folder, - [string] $Name = $null, - [string] $CurrentVersion, - [string] $EffectiveTargetVersion, - [string] $EffectiveChangeType = 'non-breaking', - [object[]] $CascadeReasons = @() - ) - if ([string]::IsNullOrEmpty($Name)) { $Name = $Folder } - $reasons = New-Object 'System.Collections.Generic.List[object]' - foreach ($r in $CascadeReasons) { [void]$reasons.Add($r) } - return [pscustomobject]@{ - Folder = $Folder - Name = $Name - CurrentVersion = $CurrentVersion - EffectiveTargetVersion = $EffectiveTargetVersion - EffectiveChangeType = $EffectiveChangeType - CascadeReasons = $reasons - } - } - - function ConvertTo-ResolvedHash { - param([object[]]$Entries) - $h = @{} - foreach ($e in $Entries) { $h[$e.Folder] = $e } - return $h - } -} - -Describe 'Get-DirectDependencyChangelogReasons' { - Context 'indirect dependent (ADO bug 7536096 repro shape)' { - # thread_aware_macros_impl <- thread_aware <- bytesbuf - # Release thread_aware_macros_impl@patch cascades to thread_aware AND - # bytesbuf. bytesbuf's Cargo.toml depends ONLY on thread_aware, so its - # changelog must name thread_aware (at its NEW version), never the - # root-cause thread_aware_macros_impl. - BeforeEach { - $script:Baseline = @( - (New-BaselinePackage -Folder 'thread_aware_macros_impl' -Version '0.7.3' -Deps @()) - (New-BaselinePackage -Folder 'thread_aware' -Version '0.7.4' -Deps @('thread_aware_macros_impl')) - (New-BaselinePackage -Folder 'bytesbuf' -Version '0.5.0' -Deps @('thread_aware')) - ) - $script:Resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'thread_aware_macros_impl' -CurrentVersion '0.7.3' -EffectiveTargetVersion '0.7.4' -EffectiveChangeType 'patch') - (New-ResolvedEntry -Folder 'thread_aware' -CurrentVersion '0.7.4' -EffectiveTargetVersion '0.7.5' -EffectiveChangeType 'patch') - (New-ResolvedEntry -Folder 'bytesbuf' -CurrentVersion '0.5.0' -EffectiveTargetVersion '0.5.1' -EffectiveChangeType 'patch') - ) - } - - It 'names only the DIRECT dependency at its new version, not the root cause' { - $entry = $script:Resolved['bytesbuf'] - $result = @(Get-DirectDependencyChangelogReasons -Entry $entry -ResolvedReleaseSet $script:Resolved -WorkspaceBaseline $script:Baseline) - - $result.Count | Should -Be 1 - $result[0].Target | Should -Be 'thread_aware' - $result[0].Version | Should -Be '0.7.5' - $result.Target | Should -Not -Contain 'thread_aware_macros_impl' - } - - It 'names the direct dependency at its new version for the middle crate too' { - $entry = $script:Resolved['thread_aware'] - $result = @(Get-DirectDependencyChangelogReasons -Entry $entry -ResolvedReleaseSet $script:Resolved -WorkspaceBaseline $script:Baseline) - - $result.Count | Should -Be 1 - $result[0].Target | Should -Be 'thread_aware_macros_impl' - $result[0].Version | Should -Be '0.7.4' - } - - It 'emits no bullet for the released root crate (no direct deps in the set)' { - $entry = $script:Resolved['thread_aware_macros_impl'] - $result = @(Get-DirectDependencyChangelogReasons -Entry $entry -ResolvedReleaseSet $script:Resolved -WorkspaceBaseline $script:Baseline) - - $result.Count | Should -Be 0 - } - } - - Context 'multiple direct dependencies' { - It 'emits one reason per direct dependency that changed, each at its new version' { - $baseline = @( - (New-BaselinePackage -Folder 'consumer' -Version '0.1.0' -Deps @('lib_a', 'lib_b', 'lib_c')) - (New-BaselinePackage -Folder 'lib_a' -Version '0.2.0') - (New-BaselinePackage -Folder 'lib_b' -Version '0.3.0') - (New-BaselinePackage -Folder 'lib_c' -Version '0.4.0') - ) - $resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'consumer' -CurrentVersion '0.1.0' -EffectiveTargetVersion '0.1.1') - (New-ResolvedEntry -Folder 'lib_a' -CurrentVersion '0.2.0' -EffectiveTargetVersion '0.2.1') - (New-ResolvedEntry -Folder 'lib_b' -CurrentVersion '0.3.0' -EffectiveTargetVersion '0.3.1') - (New-ResolvedEntry -Folder 'lib_c' -CurrentVersion '0.4.0' -EffectiveTargetVersion '0.4.1') - ) - - $result = @(Get-DirectDependencyChangelogReasons -Entry $resolved['consumer'] -ResolvedReleaseSet $resolved -WorkspaceBaseline $baseline) - - $result.Count | Should -Be 3 - ($result | ForEach-Object { "$($_.Target)@$($_.Version)" } | Sort-Object) | - Should -Be @('lib_a@0.2.1', 'lib_b@0.3.1', 'lib_c@0.4.1') - } - } - - Context 'filtering' { - It 'excludes a direct dependency whose version did not change' { - $baseline = @( - (New-BaselinePackage -Folder 'consumer' -Version '0.1.0' -Deps @('changed', 'unchanged')) - (New-BaselinePackage -Folder 'changed' -Version '0.2.0') - (New-BaselinePackage -Folder 'unchanged' -Version '0.3.0') - ) - $resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'consumer' -CurrentVersion '0.1.0' -EffectiveTargetVersion '0.1.1') - (New-ResolvedEntry -Folder 'changed' -CurrentVersion '0.2.0' -EffectiveTargetVersion '0.2.1') - (New-ResolvedEntry -Folder 'unchanged' -CurrentVersion '0.3.0' -EffectiveTargetVersion '0.3.0') - ) - - $result = @(Get-DirectDependencyChangelogReasons -Entry $resolved['consumer'] -ResolvedReleaseSet $resolved -WorkspaceBaseline $baseline) - - $result.Count | Should -Be 1 - $result[0].Target | Should -Be 'changed' - } - - It 'excludes a direct dependency that is not part of the release set' { - $baseline = @( - (New-BaselinePackage -Folder 'consumer' -Version '0.1.0' -Deps @('in_set', 'serde')) - (New-BaselinePackage -Folder 'in_set' -Version '0.2.0') - ) - $resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'consumer' -CurrentVersion '0.1.0' -EffectiveTargetVersion '0.1.1') - (New-ResolvedEntry -Folder 'in_set' -CurrentVersion '0.2.0' -EffectiveTargetVersion '0.2.1') - ) - - $result = @(Get-DirectDependencyChangelogReasons -Entry $resolved['consumer'] -ResolvedReleaseSet $resolved -WorkspaceBaseline $baseline) - - $result.Count | Should -Be 1 - $result[0].Target | Should -Be 'in_set' - } - - It 'returns empty when the dependent has no direct deps in the release set' { - $baseline = @( - (New-BaselinePackage -Folder 'consumer' -Version '0.1.0' -Deps @('external_only')) - ) - $resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'consumer' -CurrentVersion '0.1.0' -EffectiveTargetVersion '0.1.1') - ) - - $result = @(Get-DirectDependencyChangelogReasons -Entry $resolved['consumer'] -ResolvedReleaseSet $resolved -WorkspaceBaseline $baseline) - - $result.Count | Should -Be 0 - } - } - - Context 'name normalization' { - It 'reports the dependency cargo name (with dashes), resolving via underscore-normalized deps' { - # Baseline .Deps store the underscore-normalized name; the resolved - # entry carries the dashed cargo name. The bullet must use the cargo - # name as declared. - $baseline = @( - (New-BaselinePackage -Folder 'http_server' -Name 'http-server' -Version '0.1.0' -Deps @('http_layer')) - (New-BaselinePackage -Folder 'http_layer' -Name 'http-layer' -Version '0.2.0') - ) - $resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'http_server' -Name 'http-server' -CurrentVersion '0.1.0' -EffectiveTargetVersion '0.1.1') - (New-ResolvedEntry -Folder 'http_layer' -Name 'http-layer' -CurrentVersion '0.2.0' -EffectiveTargetVersion '0.2.1') - ) - - $result = @(Get-DirectDependencyChangelogReasons -Entry $resolved['http_server'] -ResolvedReleaseSet $resolved -WorkspaceBaseline $baseline) - - $result.Count | Should -Be 1 - $result[0].Target | Should -Be 'http-layer' - $result[0].Version | Should -Be '0.2.1' - } - } - - Context 'breaking flag drives section selection (preserves old per-edge aggregate)' { - It 'marks reasons Breaking when an edge in the entry''s CascadeReasons is breaking' { - $baseline = @( - (New-BaselinePackage -Folder 'app' -Version '1.2.0' -Deps @('engine')) - (New-BaselinePackage -Folder 'engine' -Version '2.0.0') - ) - $resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'app' -CurrentVersion '1.2.0' -EffectiveTargetVersion '2.0.0' -EffectiveChangeType 'breaking' ` - -CascadeReasons @([pscustomobject]@{ Target = 'engine'; Breaking = $true })) - (New-ResolvedEntry -Folder 'engine' -CurrentVersion '2.0.0' -EffectiveTargetVersion '3.0.0' -EffectiveChangeType 'breaking') - ) - - $result = @(Get-DirectDependencyChangelogReasons -Entry $resolved['app'] -ResolvedReleaseSet $resolved -WorkspaceBaseline $baseline) - - $result.Count | Should -Be 1 - $result[0].Breaking | Should -BeTrue - } - - It 'leaves reasons non-breaking when every edge in CascadeReasons is non-breaking' { - $baseline = @( - (New-BaselinePackage -Folder 'app' -Version '1.2.0' -Deps @('engine')) - (New-BaselinePackage -Folder 'engine' -Version '2.0.0') - ) - $resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'app' -CurrentVersion '1.2.0' -EffectiveTargetVersion '1.3.0' -EffectiveChangeType 'non-breaking' ` - -CascadeReasons @([pscustomobject]@{ Target = 'engine'; Breaking = $false })) - (New-ResolvedEntry -Folder 'engine' -CurrentVersion '2.0.0' -EffectiveTargetVersion '3.0.0' -EffectiveChangeType 'breaking') - ) - - $result = @(Get-DirectDependencyChangelogReasons -Entry $resolved['app'] -ResolvedReleaseSet $resolved -WorkspaceBaseline $baseline) - - $result.Count | Should -Be 1 - $result[0].Breaking | Should -BeFalse - } - - It 'keeps the bullet non-breaking for a crate that is a BREAKING user target but a NON-BREAKING cascade dependent (ADO 7536096 reviewer counterexample)' { - # dependent is released breaking via its OWN request, but the cascade - # edge dependency->dependent is non-breaking. The "Now requires" bullet - # must land under Maintenance (Breaking=$false), driven by the per-edge - # CascadeReasons flag — NOT by the dependent's own breaking change type. - $baseline = @( - (New-BaselinePackage -Folder 'dependent' -Version '1.0.0' -Deps @('dependency')) - (New-BaselinePackage -Folder 'dependency' -Version '0.2.0') - ) - $resolved = ConvertTo-ResolvedHash @( - (New-ResolvedEntry -Folder 'dependent' -CurrentVersion '1.0.0' -EffectiveTargetVersion '2.0.0' -EffectiveChangeType 'breaking' ` - -CascadeReasons @([pscustomobject]@{ Target = 'dependency'; Breaking = $false })) - (New-ResolvedEntry -Folder 'dependency' -CurrentVersion '0.2.0' -EffectiveTargetVersion '0.2.1' -EffectiveChangeType 'patch') - ) - - $result = @(Get-DirectDependencyChangelogReasons -Entry $resolved['dependent'] -ResolvedReleaseSet $resolved -WorkspaceBaseline $baseline) - - $result.Count | Should -Be 1 - $result[0].Target | Should -Be 'dependency' - $result[0].Version | Should -Be '0.2.1' - $result[0].Breaking | Should -BeFalse -Because 'section selection follows the per-edge cascade flag, not the dependent''s own change type' - } - } -} diff --git a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 index a870b4055..14db4320c 100644 --- a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 @@ -428,60 +428,6 @@ Describe 'Get-WorkspacePackages: proc-macro target classification' { $library.IsProcMacroOnly | Should -BeFalse $library.HasLibraryTarget | Should -BeTrue } - - It 'returns manual without invoking cargo-semver-checks for a proc-macro-only package' { - Reset-ReleaseScriptCaches - Mock -CommandName Invoke-CrateSemverCheck -MockWith { - throw 'Invoke-CrateSemverCheck must not run for proc-macro-only packages.' - } - - Get-CrateRequiredChangeType ` - -Folder 'macros' ` - -CargoName 'macros' ` - -RepoRoot $script:ProcMacroWorkspace.Path | Should -Be 'manual' - - Should -Invoke -CommandName Invoke-CrateSemverCheck -Times 0 -Exactly - } - - It 'continues invoking cargo-semver-checks for an ordinary library package' { - Reset-ReleaseScriptCaches - Mock -CommandName Invoke-CrateSemverCheck -MockWith { 'patch' } - - Get-CrateRequiredChangeType ` - -Folder 'library' ` - -CargoName 'library' ` - -RepoRoot $script:ProcMacroWorkspace.Path | Should -Be 'patch' - - Should -Invoke -CommandName Invoke-CrateSemverCheck -Times 1 -Exactly - } -} - -Describe 'Get-AllTransitiveDependents' { - BeforeAll { - Reset-ReleaseScriptCaches - $script:Ws = New-SyntheticWorkspace -Preset Diamond4 -Path (Join-Path $TestDrive 'transitive-diamond') - # Diamond4: top -> {left, right}; left -> bottom; right -> bottom. Dependents of bottom are {left, right, top}. - } - - It 'finds dependents through both diamond legs (deduped)' { - $deps = Get-AllTransitiveDependents -packageName 'bottom' -repoRoot $script:Ws.Path - ($deps | Sort-Object) | Should -Be @('left', 'right', 'top') - } - - It 'returns no dependents for a leaf (top) package' { - # 'top' is the top of the diamond; nothing depends on it. - $deps = @(Get-AllTransitiveDependents -packageName 'top' -repoRoot $script:Ws.Path) - $deps.Count | Should -Be 0 - } - - It 'excludes publish=false packages from the result' { - # Use Mixed6 — utility is publish=false and depends on dependent_y. - Reset-ReleaseScriptCaches - $mixed = New-SyntheticWorkspace -Preset Mixed6 -Path (Join-Path $TestDrive 'transitive-mixed') - $deps = Get-AllTransitiveDependents -packageName 'dependent_y' -repoRoot $mixed.Path - # utility depends on dependent_y but is publish=false; should not appear. - $deps | Should -Not -Contain 'utility' - } } Describe 'Get-PackagesWithUnreleasedChanges' { @@ -494,6 +440,8 @@ Describe 'Get-PackagesWithUnreleasedChanges' { } It 'reports committed source edits as unreleased' { + $files = Get-PackageUnreleasedChangeFiles -RepoRoot $script:Ws.Path + $files['b'] | Should -Contain 'crates/b/src/lib.rs' $changes = Get-PackagesWithUnreleasedChanges -RepoRoot $script:Ws.Path $changes.ContainsKey('b') | Should -BeTrue $changes['b'] | Should -BeGreaterOrEqual 1 @@ -504,6 +452,8 @@ Describe 'Get-PackagesWithUnreleasedChanges' { $w2 = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive 'unreleasedworking') # Uncommitted source edit on c. $w2.ModifySource('c') + $files = Get-PackageUnreleasedChangeFiles -RepoRoot $w2.Path + $files['c'] | Should -Contain 'crates/c/src/lib.rs' $changes = Get-PackagesWithUnreleasedChanges -RepoRoot $w2.Path $changes.ContainsKey('c') | Should -BeTrue } @@ -513,6 +463,8 @@ Describe 'Get-PackagesWithUnreleasedChanges' { $w3 = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive 'unreleaseduntracked') $newFile = Join-Path $w3.Path 'crates\a\src\new_file.rs' Set-Content -Path $newFile -Value '// new' + $files = Get-PackageUnreleasedChangeFiles -RepoRoot $w3.Path + $files['a'] | Should -Contain 'crates/a/src/new_file.rs' $changes = Get-PackagesWithUnreleasedChanges -RepoRoot $w3.Path $changes.ContainsKey('a') | Should -BeTrue } @@ -525,6 +477,30 @@ Describe 'Get-PackagesWithUnreleasedChanges' { $changes = Get-PackagesWithUnreleasedChanges -RepoRoot $w4.Path $changes.ContainsKey('utility') | Should -BeFalse } + + It 'orders changed paths ordinally across cultures' { + Reset-ReleaseScriptCaches + $w5 = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'unreleased-order') + $folder = (Get-WorkspacePackages -repoRoot $w5.Path)[0].Folder + $source = Join-Path $w5.Path "crates\$folder\src" + Set-Content -Path (Join-Path $source 'I.rs') -Value '// I' + Set-Content -Path (Join-Path $source "$([char]0x131).rs") -Value '// dotless i' + $originalCulture = [Globalization.CultureInfo]::CurrentCulture + try { + [Globalization.CultureInfo]::CurrentCulture = 'tr-TR' + $turkish = @(( + Get-PackageUnreleasedChangeFiles -RepoRoot $w5.Path + )[$folder]) + [Globalization.CultureInfo]::CurrentCulture = 'en-US' + Reset-ReleaseScriptCaches + $english = @(( + Get-PackageUnreleasedChangeFiles -RepoRoot $w5.Path + )[$folder]) + $turkish | Should -Be $english + } finally { + [Globalization.CultureInfo]::CurrentCulture = $originalCulture + } + } } Describe 'Get-PackagesWithVersionChanges' { @@ -570,70 +546,6 @@ Describe 'Get-PackagesWithVersionChanges' { } } -Describe 'Get-PendingReleases' { - BeforeEach { - Reset-ReleaseScriptCaches - } - - It 'returns an empty array when no version diffs against the base ref' { - $ws = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive ('pend-empty-' + [guid]::NewGuid().Guid.Substring(0,8))) - $pending = @(Get-PendingReleases -RepoRoot $ws.Path -BaseRef 'HEAD') - $pending.Count | Should -Be 0 - } - - It 'reports one record per pending package with Folder/Name/BaseVersion/CurrentVersion' { - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive ('pend-single-' + [guid]::NewGuid().Guid.Substring(0,8))) - # Change version of 'b' on top of HEAD baseline (uncommitted — that's the "pending" state). - $ws.SetVersion('b', '0.2.2') - - $pending = @(Get-PendingReleases -RepoRoot $ws.Path -BaseRef 'HEAD') - $pending.Count | Should -Be 1 - $pending[0].Folder | Should -Be 'b' - $pending[0].Name | Should -Be 'b' - $pending[0].BaseVersion | Should -Be '0.2.0' - $pending[0].CurrentVersion | Should -Be '0.2.2' - } - - It 'sorts pending records by Folder ascending so the announcement is deterministic' { - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive ('pend-sort-' + [guid]::NewGuid().Guid.Substring(0,8))) - # Change versions in reverse Folder order — the helper must still emit them in alphabetical order. - $ws.SetVersion('c', '0.3.1') - $ws.SetVersion('a', '0.1.1') - $ws.SetVersion('b', '0.2.1') - - $pending = @(Get-PendingReleases -RepoRoot $ws.Path -BaseRef 'HEAD') - ($pending | ForEach-Object { $_.Folder }) -join ',' | Should -Be 'a,b,c' - } - - It 'skips new-at-base packages (no base version to compare against)' { - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive ('pend-new-' + [guid]::NewGuid().Guid.Substring(0,8))) - # Change version of existing 'a' so we have at least one genuinely-pending entry to compare against. - $ws.SetVersion('a', '0.1.1') - - # Manually scaffold a brand-new package that doesn't exist at HEAD (no add+commit). - $newPackage = Join-Path $ws.Path 'crates\brandnew' - New-Item -ItemType Directory -Path $newPackage -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $newPackage 'src') -Force | Out-Null - Set-Content -Path (Join-Path $newPackage 'Cargo.toml') -Value @" -[package] -name = "brandnew" -version = "0.1.0" -edition = "2021" -"@ -NoNewline - Set-Content -Path (Join-Path $newPackage 'src\lib.rs') -Value '' -NoNewline - - $pending = @(Get-PendingReleases -RepoRoot $ws.Path -BaseRef 'HEAD') - ($pending | ForEach-Object { $_.Folder }) | Should -Be @('a') - } - - It 'rejects empty BaseRef (mandatory parameter)' { - $ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive ('pend-norefs-' + [guid]::NewGuid().Guid.Substring(0,8))) - $ws.SetVersion('a', '0.1.1') - - { Get-PendingReleases -RepoRoot $ws.Path -BaseRef '' } | Should -Throw - } -} - Describe 'Get-FileLineEnding' { It 'returns LF for a file with only LF endings' { $path = Join-Path $TestDrive ('eol-lf-' + [guid]::NewGuid().Guid.Substring(0,8) + '.txt') @@ -765,13 +677,4 @@ Describe 'Session-scoped git caches' { } } - Context 'Invalidate-WorkspaceMetadataCache' { - It 'does NOT clear git-derived caches (production cascade calls must not undo the speed-up)' { - $v1 = Get-PackageVersionFromRef -RepoRoot $script:CacheWs.Path -BaseRef 'HEAD' -PackageFolder 'b' - Invalidate-WorkspaceMetadataCache - Mock -CommandName Invoke-Git -MockWith { throw "Invalidate-WorkspaceMetadataCache must leave git caches intact" } - $v2 = Get-PackageVersionFromRef -RepoRoot $script:CacheWs.Path -BaseRef 'HEAD' -PackageFolder 'b' - $v2 | Should -Be $v1 - } - } } diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 325a9f20c..c51711fcb 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -166,54 +166,6 @@ Describe 'Test-IsBreakingChange' { } } -Describe 'Test-ValidVersion' { - It 'accepts SemVer triples' { - Test-ValidVersion -version '1.2.3' | Should -BeTrue - Test-ValidVersion -version '0.0.0' | Should -BeTrue - Test-ValidVersion -version '99.999.9999' | Should -BeTrue - } - - It 'accepts empty string (optional)' { - Test-ValidVersion -version '' | Should -BeTrue - Test-ValidVersion -version $null | Should -BeTrue - } - - It 'accepts SemVer 2.0 pre-release identifiers' { - Test-ValidVersion -version '1.2.3-alpha' | Should -BeTrue - Test-ValidVersion -version '1.2.3-pre01' | Should -BeTrue - Test-ValidVersion -version '1.2.3-rc.1' | Should -BeTrue - Test-ValidVersion -version '1.0.0-alpha.beta' | Should -BeTrue - } - - It 'accepts SemVer 2.0 build metadata' { - Test-ValidVersion -version '1.2.3+build' | Should -BeTrue - Test-ValidVersion -version '1.2.3+exp.sha.5' | Should -BeTrue - Test-ValidVersion -version '1.0.0-rc.1+meta' | Should -BeTrue - } - - It 'rejects short / long forms' { - Test-ValidVersion -version '1.2' | Should -BeFalse - Test-ValidVersion -version '1' | Should -BeFalse - Test-ValidVersion -version '1.2.3.4'| Should -BeFalse - } - - It 'rejects non-numeric components' { - Test-ValidVersion -version '1.x.3' | Should -BeFalse - } - - It 'rejects leading-zero numeric components (per SemVer 2.0)' { - Test-ValidVersion -version '01.2.3' | Should -BeFalse - Test-ValidVersion -version '1.02.3' | Should -BeFalse - Test-ValidVersion -version '1.2.03' | Should -BeFalse - } - - It 'rejects malformed pre-release / build suffixes' { - Test-ValidVersion -version '1.2.3-' | Should -BeFalse - Test-ValidVersion -version '1.2.3+' | Should -BeFalse - Test-ValidVersion -version '1.2.3-01' | Should -BeFalse # leading zero in numeric pre-release identifier - } -} - Describe 'Split-SemanticVersion' { It 'splits a plain SemVer triple' { $parts = Split-SemanticVersion -version '1.2.3' @@ -246,35 +198,6 @@ Describe 'Split-SemanticVersion' { } } -Describe 'Test-ValidPackageName' { - It 'accepts simple alpha names' { - Test-ValidPackageName -packageName 'foo' | Should -BeTrue - Test-ValidPackageName -packageName 'foo_bar' | Should -BeTrue - Test-ValidPackageName -packageName 'foo-bar' | Should -BeTrue - } - - It 'accepts digits inside' { - Test-ValidPackageName -packageName 'crate1' | Should -BeTrue - Test-ValidPackageName -packageName '1crate' | Should -BeTrue - } - - It 'rejects empty and overly long names' { - Test-ValidPackageName -packageName '' | Should -BeFalse - Test-ValidPackageName -packageName ('a' * 65) | Should -BeFalse - } - - It 'rejects edge underscores/hyphens' { - Test-ValidPackageName -packageName '-foo' | Should -BeFalse - Test-ValidPackageName -packageName 'foo-' | Should -BeFalse - } - - It 'rejects whitespace and special chars' { - Test-ValidPackageName -packageName 'foo bar' | Should -BeFalse - Test-ValidPackageName -packageName 'foo.bar' | Should -BeFalse - Test-ValidPackageName -packageName 'foo/bar' | Should -BeFalse - } -} - Describe 'ConvertFrom-SemverChecksOutput' { It 'maps a major-check failure to breaking' { $out = " Summary semver requires new major version: 3 major and 0 minor checks failed" @@ -312,30 +235,6 @@ Describe 'ConvertFrom-SemverChecksOutput' { } } -Describe 'Get-StrongerChangeType' { - It 'returns the higher-ranked change type' { - Get-StrongerChangeType 'patch' 'breaking' | Should -Be 'breaking' - Get-StrongerChangeType 'breaking' 'patch' | Should -Be 'breaking' - Get-StrongerChangeType 'patch' 'non-breaking' | Should -Be 'non-breaking' - Get-StrongerChangeType 'non-breaking' 'patch' | Should -Be 'non-breaking' - } - - It 'treats none as below patch' { - Get-StrongerChangeType 'patch' 'none' | Should -Be 'patch' - Get-StrongerChangeType 'none' 'patch' | Should -Be 'patch' - Get-StrongerChangeType 'none' 'none' | Should -Be 'none' - } - - It 'treats unknown/empty inputs as none (rank 0)' { - Get-StrongerChangeType 'breaking' '' | Should -Be 'breaking' - Get-StrongerChangeType $null 'patch' | Should -Be 'patch' - } - - It 'returns the first argument on a tie' { - Get-StrongerChangeType 'non-breaking' 'non-breaking' | Should -Be 'non-breaking' - } -} - Describe 'Get-PackageFolderForPath' { It 'returns package folder for files under crates//' { Get-PackageFolderForPath -Path 'crates/foo/src/lib.rs' | Should -Be 'foo' @@ -361,7 +260,7 @@ Describe 'Get-PackageFolderForPath' { Describe 'Sort-KeysByPreferredOrder' { BeforeAll { - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') + . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\changelog.ps1') } It 'places preferred keys first in declared order' { @@ -387,7 +286,7 @@ Describe 'Sort-KeysByPreferredOrder' { Describe 'Format-ConventionalCommits' { BeforeAll { - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') + . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\changelog.ps1') } It 'returns an empty array for no commits' { @@ -461,74 +360,6 @@ Describe 'Format-ConventionalCommits' { } } -Describe 'Reduce-DependencyChains' { - It 'returns an empty array when given no chains' { - $out = Reduce-DependencyChains -Chains @() - @($out).Count | Should -Be 0 - } - - It 'keeps a single chain unchanged' { - $out = Reduce-DependencyChains -Chains @(, @('foo', 'bar', 'baz')) - @($out).Count | Should -Be 1 - $out[0] -join '|' | Should -Be 'foo|bar|baz' - } - - It 'deduplicates identical chains' { - $out = Reduce-DependencyChains -Chains @(@('a', 'b'), @('a', 'b')) - @($out).Count | Should -Be 1 - } - - It 'drops a chain that is a strict suffix of another chain' { - # 'bar -> baz' is fully contained as the tail of 'foo -> bar -> baz'. - $out = Reduce-DependencyChains -Chains @(@('bar', 'baz'), @('foo', 'bar', 'baz')) - @($out).Count | Should -Be 1 - $out[0] -join '|' | Should -Be 'foo|bar|baz' - } - - It 'preserves multiple non-subsuming chains with different roots and intermediates' { - $out = Reduce-DependencyChains -Chains @( - @('foo', 'bar', 'baz'), - @('quu', 'nuu', 'baz'), - @('lurk', 'baz') - ) - @($out).Count | Should -Be 3 - # Output is sorted alphabetically by joined chain text. - ($out | ForEach-Object { $_ -join ' -> ' }) -join '|' | - Should -Be 'foo -> bar -> baz|lurk -> baz|quu -> nuu -> baz' - } - - It 'does NOT drop a shorter chain that is NOT a tail-aligned suffix' { - # 'b -> c' is not a suffix of 'a -> b -> d' (last element differs). - $out = Reduce-DependencyChains -Chains @(@('a', 'b', 'd'), @('b', 'c')) - @($out).Count | Should -Be 2 - } - - It 'does NOT drop a shorter chain that overlaps the head, not the tail, of a longer chain' { - # 'foo -> bar' overlaps the head of 'foo -> bar -> baz' but is not a suffix. - $out = Reduce-DependencyChains -Chains @(@('foo', 'bar'), @('foo', 'bar', 'baz')) - @($out).Count | Should -Be 2 - } - - It 'collapses several chains into one when all are nested suffixes' { - $out = Reduce-DependencyChains -Chains @( - @('d'), - @('c', 'd'), - @('b', 'c', 'd'), - @('a', 'b', 'c', 'd') - ) - @($out).Count | Should -Be 1 - $out[0] -join '|' | Should -Be 'a|b|c|d' - } - - It 'returns chains in stable alphabetical order regardless of input order' { - $a = Reduce-DependencyChains -Chains @(@('z', 'baz'), @('a', 'baz')) - $b = Reduce-DependencyChains -Chains @(@('a', 'baz'), @('z', 'baz')) - ($a | ForEach-Object { $_ -join ' -> ' }) -join '|' | - Should -Be (($b | ForEach-Object { $_ -join ' -> ' }) -join '|') - ($a | ForEach-Object { $_ -join ' -> ' }) -join '|' | Should -Be 'a -> baz|z -> baz' - } -} - Describe 'Test-PackageExposesTarget' { BeforeAll { function New-Dependent { @@ -684,6 +515,7 @@ Describe 'Test-PackageAllowlistNamesTarget' { param($Allowed, $DepAliases = @{}) [pscustomobject]@{ AllowedExternalTypes = $Allowed; DepAliases = $DepAliases } } + } It 'reports a match when an entry is rooted at the target package' { @@ -762,6 +594,19 @@ Describe 'Test-PackageAllowlistNamesTarget' { -TargetPackageName 'recoverable' | Should -BeTrue -Because "'$pattern' may expand to the target" } } + + It 'ignores wildcard roots when concrete evidence is required' { + foreach ($allowed in @( + @('*', 'recoverable::Recovery'), + @('recoverable::Recovery', '*') + )) { + Test-PackageAllowlistNamesTarget ` + -Dependent (New-Dependent -Allowed $allowed) ` + -TargetPackageName 'recoverable' ` + -WildcardIsEvidence $false | + Should -BeTrue + } + } } It 'reports no match for an explicit empty allowlist' { @@ -774,3 +619,305 @@ Describe 'Test-PackageAllowlistNamesTarget' { Test-PackageAllowlistNamesTarget -Dependent $dep -TargetPackageName 'recoverable' | Should -BeTrue } } + +Describe 'Test-PackageAllowlistNamesDirectTarget' { + It 'requires positive allowlist evidence' { + $dependent = [pscustomobject]@{ + AllowedExternalTypes = @('macro_crate::derive_item') + DepAliases = @{} + } + + Test-PackageAllowlistNamesDirectTarget ` + -Dependent $dependent ` + -TargetPackageName 'macro-crate' | + Should -BeTrue + } + + It 'recognizes a renamed proc-macro dependency' { + $dependent = [pscustomobject]@{ + AllowedExternalTypes = @('derive_alias::derive_item') + DepAliases = @{ macro_crate = @('derive_alias') } + } + + Test-PackageAllowlistNamesDirectTarget ` + -Dependent $dependent ` + -TargetPackageName 'macro-crate' | + Should -BeTrue + } + + It 'does not accept the hidden package name on a renamed dependency' { + $dependent = [pscustomobject]@{ + AllowedExternalTypes = @('macro_crate::derive_item') + DepAliases = @{ macro_crate = @('derive_alias') } + } + + Test-PackageAllowlistNamesDirectTarget ` + -Dependent $dependent ` + -TargetPackageName 'macro-crate' | + Should -BeFalse + } + + It 'accepts every nameable root when renamed and unrenamed edges coexist' { + $dependent = [pscustomobject]@{ + AllowedExternalTypes = @('macro_crate::derive_item') + DepRoots = @{ macro_crate = @('derive_alias', 'macro_crate') } + DepAliases = @{ macro_crate = @('derive_alias') } + } + + Test-PackageAllowlistNamesDirectTarget ` + -Dependent $dependent ` + -TargetPackageName 'macro-crate' | + Should -BeTrue + + $dependent.AllowedExternalTypes = @('derive_alias::derive_item') + Test-PackageAllowlistNamesDirectTarget ` + -Dependent $dependent ` + -TargetPackageName 'macro-crate' | + Should -BeTrue + } + + It 'does not infer macro publication from missing or malformed metadata' { + foreach ($allowed in @($null, @(), @($null), @(''), @('*'))) { + $dependent = [pscustomobject]@{ + AllowedExternalTypes = $allowed + DepAliases = @{} + } + + Test-PackageAllowlistNamesDirectTarget ` + -Dependent $dependent ` + -TargetPackageName 'macro-crate' | + Should -BeFalse + } + } +} + +Describe 'Get-CargoCompatibilityLine' { + It 'returns the leading non-zero component span' { + Get-CargoCompatibilityLine -Version '1.4.2' | Should -Be '1' + Get-CargoCompatibilityLine -Version '2.0.0' | Should -Be '2' + Get-CargoCompatibilityLine -Version '0.5.3' | Should -Be '0.5' + Get-CargoCompatibilityLine -Version '0.0.3' | Should -Be '0.0.3' + Get-CargoCompatibilityLine -Version '0' | Should -Be '0' + Get-CargoCompatibilityLine -Version '0.0' | Should -Be '0.0' + } + + It 'ignores pre-release and build metadata' { + Get-CargoCompatibilityLine -Version '1.2.3-beta.1' | Should -Be '1' + Get-CargoCompatibilityLine -Version '0.5.0+build.7' | Should -Be '0.5' + } + + It 'returns null for versions it cannot read' { + Get-CargoCompatibilityLine -Version '1.x' | Should -BeNullOrEmpty + Get-CargoCompatibilityLine -Version '*' | Should -BeNullOrEmpty + Get-CargoCompatibilityLine -Version '' | Should -BeNullOrEmpty + } +} + +Describe 'Get-CargoRequirementLines' { + It 'reads caret, bare, tilde and exact requirements' { + Get-CargoRequirementLines -Requirement '^3.0.2' | Should -Be @('3') + Get-CargoRequirementLines -Requirement '3.0.2' | Should -Be @('3') + Get-CargoRequirementLines -Requirement '~1.2.3' | Should -Be @('1') + Get-CargoRequirementLines -Requirement '=0.5.1' | Should -Be @('0.5') + } + + It 'returns null for requirements whose line cannot be decided' { + foreach ($requirement in @('*', '1.*', '>=1, <3', '>1.0', 'x.y', '', $null)) { + Get-CargoRequirementLines -Requirement $requirement | Should -BeNullOrEmpty + } + } + + It 'accepts comma-separated comparators only when they agree on one line' { + Get-CargoRequirementLines -Requirement '^1.2, ^1.4' | Should -Be @('1') + Get-CargoRequirementLines -Requirement '^1.2, ^2.0' | Should -BeNullOrEmpty + } +} + +Describe 'Get-NormalizedCargoRequirement' { + It 'spells a bare version the way cargo does' { + Get-NormalizedCargoRequirement -Requirement '3.0.2' | Should -Be '^3.0.2' + Get-NormalizedCargoRequirement -Requirement ' 3.0.2 ' | Should -Be '^3.0.2' + Get-NormalizedCargoRequirement -Requirement '^3.0.2' | Should -Be '^3.0.2' + } + + It 'is idempotent over multi-declaration joins' { + $joined = Join-CargoRequirements -Requirements @('0.2', '^0.4.0') + Get-NormalizedCargoRequirement -Requirement $joined | Should -Be $joined + } + + It 'returns null for an absent requirement' { + Get-NormalizedCargoRequirement -Requirement $null | Should -BeNullOrEmpty + Get-NormalizedCargoRequirement -Requirement ' ' | Should -BeNullOrEmpty + } +} + +Describe 'Join-CargoRequirements' { + It 'normalizes and orders the declarations deterministically' { + Join-CargoRequirements -Requirements @('^0.4.0', '0.2') | + Should -Be (Join-CargoRequirements -Requirements @('0.2', '^0.4.0')) + } + + It 'collapses repeated declarations of one requirement' { + Join-CargoRequirements -Requirements @('1.0', '^1.0') | Should -Be '^1.0' + } + + It 'produces a requirement no grammar admits when declarations disagree' { + $joined = Join-CargoRequirements -Requirements @('0.2', '0.4.0') + Get-CargoRequirementLines -Requirement $joined | Should -BeNullOrEmpty + } +} + +Describe 'Test-CargoRequirementBreaking' { + It 'is breaking when the compatibility line moves' { + Test-CargoRequirementBreaking -BaselineRequirement '^2.0.111' -CurrentRequirement '^3.0.2' | + Should -BeTrue + Test-CargoRequirementBreaking -BaselineRequirement '^0.5.1' -CurrentRequirement '^0.6.0' | + Should -BeTrue + } + + It 'is not breaking within one compatibility line' { + Test-CargoRequirementBreaking -BaselineRequirement '^2.0.111' -CurrentRequirement '^2.9.0' | + Should -BeFalse + Test-CargoRequirementBreaking -BaselineRequirement '2.0.111' -CurrentRequirement '^2.0.111' | + Should -BeFalse + Test-CargoRequirementBreaking -BaselineRequirement '^0.5.1' -CurrentRequirement '^0.5.9' | + Should -BeFalse + } + + It 'treats a newly declared dependency as non-breaking and a dropped one as breaking' { + Test-CargoRequirementBreaking -BaselineRequirement $null -CurrentRequirement '^1.0' | + Should -BeFalse + Test-CargoRequirementBreaking -BaselineRequirement '^1.0' -CurrentRequirement $null | + Should -BeTrue + } + + It 'fails closed on a requirement it cannot read' { + Test-CargoRequirementBreaking -BaselineRequirement '^1.0' -CurrentRequirement '*' | + Should -BeTrue + Test-CargoRequirementBreaking -BaselineRequirement '>=1, <3' -CurrentRequirement '^1.0' | + Should -BeTrue + } +} + +Describe 'Get-CargoManifestDependencies' { + It 'resolves workspace inheritance from the root requirements' { + $root = @( + '[workspace.dependencies]' + 'syn = { version = "2.0.111" }' + 'quote = "1.0.42"' + ) -join "`n" + $package = @( + '[package]' + 'name = "alpha"' + '' + '[dependencies]' + 'syn = { workspace = true, features = [' + ' "full",' + '] }' + 'quote.workspace = true' + ) -join "`n" + + $requirements = Get-CargoWorkspaceRequirements -ManifestText $root + $deps = Get-CargoManifestDependencies ` + -ManifestText $package ` + -WorkspaceRequirements $requirements + + $deps['syn'].Requirement | Should -Be '^2.0.111' + $deps['quote'].Requirement | Should -Be '^1.0.42' + } + + It 'drops an inherited dependency the workspace does not declare' { + $package = @( + '[dependencies]' + 'syn = { workspace = true }' + ) -join "`n" + + $deps = Get-CargoManifestDependencies -ManifestText $package + @($deps.Keys).Count | Should -Be 0 + } + + It 'never reads dev-dependencies' { + $package = @( + '[dependencies]' + 'serde = "1.0"' + '' + '[dev-dependencies]' + 'proptest = "1.5"' + ) -join "`n" + + $deps = Get-CargoManifestDependencies -ManifestText $package + @($deps.Keys) | Should -Be @('serde') + } + + It 'records normal and build declarations of one dependency together' { + $package = @( + '[dependencies]' + 'syn = "2.0.111"' + '' + '[build-dependencies]' + 'syn = "2.0.111"' + ) -join "`n" + + $deps = Get-CargoManifestDependencies -ManifestText $package + $deps['syn'].Kinds | Should -Be @('build', 'normal') + $deps['syn'].Requirement | Should -Be '^2.0.111' + } + + It 'reads target-specific and sub-table declarations' { + $package = @( + "[target.'cfg(unix)'.dependencies]" + 'libc = "0.2.178"' + '' + '[dependencies.serde]' + 'version = "1.0.200"' + 'features = ["derive"]' + ) -join "`n" + + $deps = Get-CargoManifestDependencies -ManifestText $package + $deps['libc'].Requirement | Should -Be '^0.2.178' + $deps['serde'].Requirement | Should -Be '^1.0.200' + } + + It 'keys a renamed dependency by its real package name' { + $package = @( + '[dependencies]' + 'allocator-api2-02 = { package = "allocator-api2", version = "0.2" }' + '' + '[dependencies.syn2]' + 'package = "syn"' + 'version = "2.0.111"' + ) -join "`n" + + $deps = Get-CargoManifestDependencies -ManifestText $package + @($deps.Keys) | Sort-Object | Should -Be @('allocator_api2', 'syn') + $deps['syn'].Requirement | Should -Be '^2.0.111' + } + + It 'ignores comments and path-only declarations' { + $package = @( + '[dependencies]' + '# syn = "9.9.9"' + 'sibling = { path = "../sibling" }' + 'serde = "1.0" # trailing' + ) -join "`n" + + $deps = Get-CargoManifestDependencies -ManifestText $package + @($deps.Keys) | Should -Be @('serde') + $deps['serde'].Requirement | Should -Be '^1.0' + } + + It 'normalizes dependency names ordinally' { + $package = @( + '[dependencies]' + 'proc-macro2 = "1.0.103"' + ) -join "`n" + + $deps = Get-CargoManifestDependencies -ManifestText $package + @($deps.Keys) | Should -Be @('proc_macro2') + } + + It 'returns an empty map for an absent manifest' { + $deps = Get-CargoManifestDependencies -ManifestText '' + @($deps.Keys).Count | Should -Be 0 + } +} diff --git a/scripts/tests/Pester/unit/releasing/ReleaseChangelog.Tests.ps1 b/scripts/tests/Pester/unit/releasing/ReleaseChangelog.Tests.ps1 new file mode 100644 index 000000000..f23c0cb3f --- /dev/null +++ b/scripts/tests/Pester/unit/releasing/ReleaseChangelog.Tests.ps1 @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Tests for the release skill's thin deterministic changelog helper. Verifies + the version header and the + cascade "Now requires X of Y" bullet are written by reusing Write-Changelog. +#> + +BeforeAll { + . (Join-Path $PSScriptRoot '..\..\_common\TestHelpers.ps1') + . (Join-Path $PSScriptRoot '..\..\_common\New-SyntheticWorkspace.ps1') + . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\releasing.ps1') + + $script:ChangelogScript = Join-Path ( + Get-OxiRepoRoot + ) '.github\skills\release-packages\scripts\release-changelog.ps1' +} + +Describe 'release-changelog.ps1' { + BeforeEach { + Reset-ReleaseScriptCaches + $script:WsRoot = Join-Path $TestDrive ("cl-ws-" + [guid]::NewGuid().ToString('N')) + $script:Ws = New-SyntheticWorkspace -Preset 'Linear2' -Path $script:WsRoot + } + + It 'inserts a dated version header for the released package' { + & $script:ChangelogScript -RepoRoot $script:WsRoot -PackageFolder 'dependency' -NewVersion '0.3.0' ` + -PrBaseUrl 'https://github.com/microsoft/oxidizer' 6>$null + + $changelog = Get-Content (Join-Path $script:WsRoot 'crates\dependency\CHANGELOG.md') -Raw + $today = (Get-Date).ToString('yyyy-MM-dd') + $changelog | Should -Match ([regex]::Escape("## [0.3.0] - $today")) + } + + It 'writes cascade "Now requires" bullets from CascadeReasonsJson' { + $reasons = '[{"Target":"dependency","Version":"0.3.0","Breaking":false}]' + & $script:ChangelogScript -RepoRoot $script:WsRoot -PackageFolder 'dependent' -NewVersion '0.2.0' ` + -PrBaseUrl 'https://github.com/microsoft/oxidizer' -CascadeReasonsJson $reasons 6>$null + + $changelog = Get-Content (Join-Path $script:WsRoot 'crates\dependent\CHANGELOG.md') -Raw + $changelog | Should -Match ([regex]::Escape('## [0.2.0] -')) + $changelog | Should -Match ([regex]::Escape('Now requires `0.3.0` of `dependency`')) + } + + It 'renders a breaking cascade under the Breaking section' { + $reasons = '[{"Target":"dependency","Version":"1.0.0","Breaking":true}]' + & $script:ChangelogScript -RepoRoot $script:WsRoot -PackageFolder 'dependent' -NewVersion '0.2.0' ` + -PrBaseUrl 'https://github.com/microsoft/oxidizer' -CascadeReasonsJson $reasons 6>$null + + $changelog = Get-Content (Join-Path $script:WsRoot 'crates\dependent\CHANGELOG.md') -Raw + $changelog | Should -Match ([regex]::Escape('Breaking')) + $changelog | Should -Match ([regex]::Escape('Now requires `1.0.0` of `dependency`')) + } + + It 'fails clearly for an unknown package folder' { + { & $script:ChangelogScript -RepoRoot $script:WsRoot -PackageFolder 'does_not_exist' -NewVersion '0.2.0' 6>$null } | + Should -Throw '*was not found under*' + } +} diff --git a/scripts/tests/Pester/unit/releasing/ReleaseFacts.Tests.ps1 b/scripts/tests/Pester/unit/releasing/ReleaseFacts.Tests.ps1 new file mode 100644 index 000000000..60b50d241 --- /dev/null +++ b/scripts/tests/Pester/unit/releasing/ReleaseFacts.Tests.ps1 @@ -0,0 +1,804 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Tests for the release skill's deterministic fact-gathering helper. Uses the + synthetic-workspace + fixture so the assertions are hermetic (no dependency on the real workspace). +#> + +BeforeAll { + . (Join-Path $PSScriptRoot '..\..\_common\TestHelpers.ps1') + . (Join-Path $PSScriptRoot '..\..\_common\New-SyntheticWorkspace.ps1') + . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\releasing.ps1') + + $script:FactsScript = Join-Path ( + Get-OxiRepoRoot + ) '.github\skills\release-packages\scripts\release-facts.ps1' + + function Invoke-ReleaseFacts { + param([Parameter(Mandatory = $true)][string]$RepoRoot) + Reset-ReleaseScriptCaches + $json = & $script:FactsScript -RepoRoot $RepoRoot + return ($json | ConvertFrom-Json) + } +} + +Describe 'release-facts.ps1' { + BeforeAll { + $script:WsRoot = Join-Path $TestDrive 'facts-ws' + $spec = @{ + Packages = @( + @{ Name = 'alpha'; Version = '0.1.0'; Deps = @(@{ Name = 'beta' }) } + @{ Name = 'beta'; Version = '0.2.0' } + @{ + Name = 'exposer' + Version = '0.2.0' + Deps = @(@{ Name = 'beta' }) + AllowedExternalTypes = @('beta::*', 'http::*', 'stale::*') + } + @{ + Name = 'gamma_macros' + Version = '0.3.0' + ProcMacro = $true + Deps = @(@{ Name = 'beta' }) + AllowedExternalTypes = @('gamma_macros::*') + } + @{ + Name = 'macro_runtime' + Version = '0.3.0' + Deps = @(@{ Name = 'gamma_macros' }) + AllowedExternalTypes = @('gamma_macros::derive_gamma') + } + @{ + Name = 'wildcard_macro_user' + Version = '0.1.0' + Deps = @(@{ Name = 'gamma_macros' }) + AllowedExternalTypes = @('*') + } + @{ + Name = 'macro_intermediate' + Version = '0.1.0' + Deps = @(@{ Name = 'gamma_macros' }) + } + @{ + Name = 'transitive_wildcard_macro_user' + Version = '0.1.0' + Deps = @(@{ Name = 'macro_intermediate' }) + AllowedExternalTypes = @('*') + } + @{ + Name = 'renamed_macro_user' + Version = '0.1.0' + Deps = @(@{ + Name = 'gamma_macros' + Rename = 'gamma_alias' + }) + AllowedExternalTypes = @('gamma_macros::derive_gamma') + } + @{ + Name = 'private_macro_user' + Version = '0.1.0' + Published = $false + Deps = @(@{ Name = 'gamma_macros' }) + AllowedExternalTypes = @('gamma_macros::derive_gamma') + } + @{ + Name = 'detached_macros' + Version = '0.1.0' + ProcMacro = $true + MacroRuntime = @('detached_runtime') + } + @{ + Name = 'detached_runtime' + Version = '0.1.0' + } + @{ + Name = 'devonly' + Version = '0.1.0' + Deps = @(@{ Name = 'beta'; Kind = 'dev' }) + } + @{ + Name = 'empty_exposer' + Version = '0.1.0' + Deps = @(@{ Name = 'beta' }) + AllowedExternalTypes = @() + } + @{ + Name = 'dual_dep' + Version = '0.1.0' + Deps = @( + @{ Name = 'beta' } + @{ Name = 'beta'; Kind = 'build' } + ) + } + @{ Name = 'priv_pkg'; Version = '0.4.0'; Published = $false } + ) + } + $script:Ws = New-SyntheticWorkspace -Spec $spec -Path $script:WsRoot + # Create an explicit version-bump commit for 'beta' so it has a real + # baseline commit (parent 0.2.0 -> commit 0.5.0). + $script:Ws.SetVersion('beta', '0.5.0') + $script:Ws.AddCommit('bump beta to 0.5.0') + + # Tag 'beta' as released so everReleased distinguishes it from the + # never-released crates (whose introducing commit also yields a baseline). + & git -C $script:Ws.Path tag 'beta-v0.5.0' 2>&1 | Out-Null + + # Leave an uncommitted source edit on 'alpha' so it registers as modified. + # The default suffix is a plain `// edit` line comment (not a doc + # comment), so alpha's rustImplementationChanged AND docCommentChanged + # must both stay false. + $script:Ws.ModifySource('alpha') + + # 'exposer' gets a real code addition, so its rustImplementationChanged + # must be true, distinguishing an implementation edit from a comment one. + $script:Ws.ModifySource('exposer', 'pub fn newly_added() -> i32 { 42 }') + + # 'dual_dep' gets a rustdoc-visible doc comment, so docCommentChanged must + # be true while rustImplementationChanged stays false. + $script:Ws.ModifySource('dual_dep', '/// A documentation comment.') + + # Also modify the UNPUBLISHED 'priv_pkg'. This makes the "publish=false is + # never surfaced" assertion meaningful: priv_pkg now has a real working-tree + # change, so modified=false can only hold because the published filter + # suppresses it -- not merely because nothing changed. + $script:Ws.ModifySource('priv_pkg') + + $script:Facts = Invoke-ReleaseFacts -RepoRoot $script:WsRoot + $script:ByFolder = @{} + foreach ($p in $script:Facts.packages) { $script:ByFolder[$p.folder] = $p } + } + + It 'emits every workspace package under crates/' { + $script:Facts.schemaVersion | Should -Be 5 + $folders = @($script:Facts.packages | ForEach-Object { $_.folder }) | Sort-Object + $folders | Should -Be @( + 'alpha', + 'beta', + 'detached_macros', + 'detached_runtime', + 'devonly', + 'dual_dep', + 'empty_exposer', + 'exposer', + 'gamma_macros', + 'macro_intermediate', + 'macro_runtime', + 'priv_pkg', + 'private_macro_user', + 'renamed_macro_user', + 'transitive_wildcard_macro_user', + 'wildcard_macro_user' + ) + } + + It 'reports name, version and published flag' { + $script:ByFolder['alpha'].name | Should -Be 'alpha' + $script:ByFolder['beta'].version | Should -Be '0.5.0' + $script:ByFolder['alpha'].published | Should -BeTrue + $script:ByFolder['priv_pkg'].published | Should -BeFalse + } + + It 'captures normal dependency edges (dev excluded)' { + @($script:ByFolder['alpha'].deps) | Should -Contain 'beta' + @($script:ByFolder['beta'].deps).Count | Should -Be 0 + @($script:ByFolder['devonly'].deps).Count | Should -Be 0 + @($script:ByFolder['dual_dep'].deps) | Should -Be @('beta') + } + + It 'emits deterministic workspace exposure edges from external-type metadata' { + $script:ByFolder['exposer'].exposureUnknown | Should -BeFalse + @($script:ByFolder['exposer'].exposedDeps) | Should -Be @('beta') + } + + It 'fails closed for a direct dependency when exposure metadata is absent' { + $script:ByFolder['alpha'].exposureUnknown | Should -BeFalse + @($script:ByFolder['alpha'].exposedDeps) | Should -Be @('beta') + } + + It 'treats an explicit empty allowlist as no exposure for libraries' { + $script:ByFolder['empty_exposer'].exposureUnknown | Should -BeFalse + @($script:ByFolder['empty_exposer'].exposedDeps).Count | Should -Be 0 + } + + It 'includes exposure properties for every package' { + foreach ($p in $script:Facts.packages) { + $p.PSObject.Properties.Name | Should -Contain 'exposedDeps' + $p.PSObject.Properties.Name | Should -Contain 'exposureUnknown' + $p.PSObject.Properties.Name | Should -Contain 'manifestOtherChanged' + } + } + + It 'flags proc-macro-only packages' { + $script:ByFolder['gamma_macros'].procMacroOnly | Should -BeTrue + $script:ByFolder['gamma_macros'].hasLibraryTarget | Should -BeFalse + $script:ByFolder['beta'].procMacroOnly | Should -BeFalse + } + + It 'does not treat proc-macro implementation dependencies as type exposure' { + $script:ByFolder['gamma_macros'].exposureUnknown | Should -BeFalse + @($script:ByFolder['gamma_macros'].exposedDeps).Count | Should -Be 0 + @($script:ByFolder['gamma_macros'].macroImplementationClosure) | + Should -Be @('beta') + } + + It 'records public proc-macro edges separately from type exposure' { + @($script:ByFolder['macro_runtime'].macroPublicDeps) | + Should -Be @('gamma_macros') + @($script:ByFolder['macro_runtime'].exposedDeps) | + Should -Not -Contain 'gamma_macros' + } + + It 'infers a generated-runtime partner from a public macro edge' { + @($script:ByFolder['gamma_macros'].macroRuntimePartners) | + Should -Be @('macro_runtime') + } + + It 'does not infer macro publication from wildcard or unpublished consumers' { + @($script:ByFolder['wildcard_macro_user'].macroPublicDeps).Count | + Should -Be 0 + @($script:ByFolder['transitive_wildcard_macro_user'].macroPublicDeps).Count | + Should -Be 0 + @($script:ByFolder['renamed_macro_user'].macroPublicDeps).Count | + Should -Be 0 + @($script:ByFolder['gamma_macros'].macroRuntimePartners) | + Should -Not -Contain 'private_macro_user' + @($script:ByFolder['gamma_macros'].macroRuntimePartners) | + Should -Not -Contain 'transitive_wildcard_macro_user' + @($script:ByFolder['gamma_macros'].macroRuntimePartners) | + Should -Not -Contain 'renamed_macro_user' + } + + It 'retains an explicit exceptional runtime relationship' { + @($script:ByFolder['detached_macros'].macroRuntimePartners) | + Should -Be @('detached_runtime') + } + + It 'resolves a baseline commit sha for a package with a prior version bump' { + $script:ByFolder['beta'].hasBaseline | Should -BeTrue + $script:ByFolder['beta'].baselineSha | Should -Match '^[0-9a-f]{40}$' + } + + It 'includes a baselineSha property for every package (possibly null)' { + foreach ($p in $script:Facts.packages) { + $p.PSObject.Properties.Name | Should -Contain 'baselineSha' + } + } + + It 'distinguishes an ever-released crate from a never-released one via everReleased' { + # beta is tagged 'beta-v0.5.0'; the others have no release tag. Every crate + # has a baselineSha (its introducing commit counts as a bump), so + # everReleased -- not hasBaseline -- is the real discriminator. + $script:ByFolder['beta'].everReleased | Should -BeTrue + $script:ByFolder['alpha'].everReleased | Should -BeFalse + $script:ByFolder['beta'].hasBaseline | Should -BeTrue + $script:ByFolder['alpha'].hasBaseline | Should -BeTrue + } + + It 'detects unreleased (working-tree) modifications' { + $script:ByFolder['alpha'].modified | Should -BeTrue + $script:ByFolder['alpha'].modifiedFileCount | Should -BeGreaterThan 0 + @($script:ByFolder['alpha'].modifiedFiles).Count | + Should -Be $script:ByFolder['alpha'].modifiedFileCount + $script:ByFolder['alpha'].modifiedFiles | + Should -Contain 'crates/alpha/src/lib.rs' + @($script:ByFolder['alpha'].manifestDependencyScopes).Count | + Should -Be 0 + $script:ByFolder['beta'].modified | Should -BeFalse + } + + It 'distinguishes a doc-comment-only edit from a real implementation edit' { + # alpha's only source change is a plain `// edit` line comment. + $script:ByFolder['alpha'].rustImplementationChanged | Should -BeFalse + $script:ByFolder['alpha'].docCommentChanged | Should -BeFalse + # exposer added a real `pub fn`. + $script:ByFolder['exposer'].rustImplementationChanged | Should -BeTrue + $script:ByFolder['exposer'].docCommentChanged | Should -BeFalse + # dual_dep added a rustdoc-visible `///` doc comment. + $script:ByFolder['dual_dep'].rustImplementationChanged | Should -BeFalse + $script:ByFolder['dual_dep'].docCommentChanged | Should -BeTrue + # An unmodified crate reports neither. + $script:ByFolder['beta'].rustImplementationChanged | Should -BeFalse + $script:ByFolder['beta'].docCommentChanged | Should -BeFalse + } + + It 'handles dependency table variants without inheriting unrelated TOML sections' { + $cases = @( + @{ + Name = 'normal' + Deps = @(@{ Name = 'beta' }) + Edit = { + param($raw) + $raw.Replace( + 'beta.workspace = true', + 'beta = { workspace = true, features = ["std"] }' + ) + } + Expected = @('normal') + }, + @{ + Name = 'padded normal header' + Deps = @(@{ Name = 'beta' }) + Edit = { + param($raw) + $raw.Replace('[dependencies]', '[ dependencies ]').Replace( + 'beta.workspace = true', + 'beta = { workspace = true, features = ["std"] }' + ) + } + Expected = @('normal') + }, + @{ + Name = 'build removal' + Deps = @(@{ Name = 'beta'; Kind = 'build' }) + Edit = { + param($raw) + $raw.Replace('beta.workspace = true', '') + } + Expected = @('build') + }, + @{ + Name = 'dev' + Deps = @(@{ Name = 'beta'; Kind = 'dev' }) + Edit = { + param($raw) + $raw.Replace( + 'beta.workspace = true', + 'beta = { workspace = true, features = ["testing"] }' + ) + } + Expected = @('dev') + }, + @{ + Name = 'target dev' + Deps = @() + Edit = { + param($raw) + "$raw`n`n[target.'cfg(windows)'.dev-dependencies]`nbeta.workspace = true" + } + Expected = @('dev') + }, + @{ + Name = 'scope move' + Deps = @(@{ Name = 'beta'; Kind = 'dev' }) + Edit = { + param($raw) + $raw.Replace('[dev-dependencies]', '[dependencies]') + } + Expected = @('normal', 'dev') + }, + @{ + Name = 'target relocation' + Deps = @(@{ Name = 'beta' }) + Edit = { + param($raw) + $raw.Replace( + '[dependencies]', + "[target.'cfg(unix)'.dependencies]" + ) + } + Expected = @('normal') + }, + @{ + Name = 'inline comment' + Deps = @(@{ Name = 'beta' }) + Edit = { + param($raw) + $raw.Replace( + 'beta.workspace = true', + 'beta.workspace = true # explanation changed' + ) + } + Expected = @() + }, + @{ + Name = 'package features' + Deps = @(@{ Name = 'beta' }) + Edit = { + param($raw) + "$raw`n`n[features]`ndefault = []`ntesting = [`"beta/testing`"]" + } + Expected = @('features') + }, + @{ + Name = 'metadata' + Deps = @(@{ Name = 'beta' }) + Edit = { + param($raw) + @" +$raw + +[package.metadata.review.dependencies] +note = "not a Cargo dependency table" +"@ + } + Expected = @() + }, + @{ + Name = 'array table' + Deps = @(@{ Name = 'beta' }) + Edit = { + param($raw) + @" +$raw + +[[example]] +name = "demo" +path = "examples/demo.rs" +"@ + } + Expected = @() + OtherChanged = $true + Example = $true + } + ) + + foreach ($case in $cases) { + $root = Join-Path $TestDrive "manifest-$($case.Name.Replace(' ', '-'))" + $ws = New-SyntheticWorkspace -Path $root -Spec @{ + Packages = @( + @{ Name = 'alpha'; Version = '0.1.0'; Deps = $case.Deps } + @{ Name = 'beta'; Version = '0.1.0' } + ) + } + if ($case.Example) { + $exampleDir = Join-Path $ws.Path 'crates\alpha\examples' + New-Item -ItemType Directory -Path $exampleDir | Out-Null + Set-Content ` + -LiteralPath (Join-Path $exampleDir 'demo.rs') ` + -Value 'fn main() {}' ` + -NoNewline + } + $manifest = Join-Path $ws.Path 'crates\alpha\Cargo.toml' + $raw = Get-Content -LiteralPath $manifest -Raw + Set-Content ` + -LiteralPath $manifest ` + -Value (& $case.Edit $raw) ` + -NoNewline + + $facts = Invoke-ReleaseFacts -RepoRoot $ws.Path + $alpha = $facts.packages | Where-Object folder -eq alpha + @($alpha.manifestDependencyScopes) | Should -Be $case.Expected + $alpha.manifestOtherChanged | + Should -Be ([bool]($case.OtherChanged ?? $false)) + } + } + + It 'detects case-only manifest changes ordinally' { + $root = Join-Path $TestDrive 'manifest-case-only' + $ws = New-SyntheticWorkspace -Path $root -Spec @{ + Packages = @( + @{ Name = 'alpha'; Version = '0.1.0' } + ) + } + $manifest = Join-Path $ws.Path 'crates\alpha\Cargo.toml' + $raw = Get-Content -LiteralPath $manifest -Raw + Set-Content ` + -LiteralPath $manifest ` + -Value "$raw`n`n[features]`ndefault = []`nstd = []" ` + -NoNewline + & git -C $ws.Path add . 2>&1 | Out-Null + & git -C $ws.Path commit --amend --no-edit 2>&1 | Out-Null + (Get-Content -LiteralPath $manifest -Raw).Replace('std = []', 'STD = []') | + Set-Content -LiteralPath $manifest -NoNewline + + $facts = Invoke-ReleaseFacts -RepoRoot $ws.Path + $alpha = $facts.packages | Where-Object folder -eq alpha + @($alpha.manifestDependencyScopes) | Should -Be @('features') + + $targetRoot = Join-Path $TestDrive 'manifest-case-only-target' + $targetWs = New-SyntheticWorkspace -Path $targetRoot -Spec @{ + Packages = @( + @{ Name = 'alpha'; Version = '0.1.0' } + @{ Name = 'beta'; Version = '0.1.0' } + ) + } + $targetManifest = Join-Path $targetWs.Path 'crates\alpha\Cargo.toml' + $targetRaw = Get-Content -LiteralPath $targetManifest -Raw + Set-Content ` + -LiteralPath $targetManifest ` + -Value "$targetRaw`n`n[target.'cfg(target_os = `"linux`")'.dependencies]`nbeta.workspace = true" ` + -NoNewline + & git -C $targetWs.Path add . 2>&1 | Out-Null + & git -C $targetWs.Path commit --amend --no-edit 2>&1 | Out-Null + (Get-Content -LiteralPath $targetManifest -Raw).Replace( + 'target_os = "linux"', + 'target_os = "LINUX"' + ) | Set-Content -LiteralPath $targetManifest -NoNewline + + $targetFacts = Invoke-ReleaseFacts -RepoRoot $targetWs.Path + $targetAlpha = $targetFacts.packages | Where-Object folder -eq alpha + @($targetAlpha.manifestDependencyScopes) | Should -Be @('normal') + } + + It 'never surfaces publish=false packages as modified' { + # priv_pkg HAS an uncommitted source edit (see BeforeAll), yet + # Get-PackagesWithUnreleasedChanges skips it because it is unpublished. If + # the published filter were removed, this assertion would fail. + $script:ByFolder['priv_pkg'].modified | Should -BeFalse + $script:ByFolder['priv_pkg'].workspaceModified | Should -BeTrue + $script:ByFolder['priv_pkg'].modifiedFiles | + Should -Contain 'crates/priv_pkg/src/lib.rs' + } + + It 'fails loudly for an unresolvable base ref instead of reporting no baseline' { + { & $script:FactsScript -RepoRoot $script:WsRoot -BaseRef 'refs/heads/no-such-ref-xyz' } | + Should -Throw '*could not be resolved*' + } +} + +Describe 'release-facts.ps1 compile-fixture obligations' { + BeforeAll { + # A proc macro whose compile fixtures live in its runtime partner -- the + # shape that let a rejected-input break ship as a patch, because in the + # partner the fixture reads as an ordinary test-only edit. + $script:FixWsRoot = Join-Path $TestDrive 'fixture-ws' + $spec = @{ + Packages = @( + @{ + Name = 'gamma_macros' + Version = '0.3.0' + ProcMacro = $true + AllowedExternalTypes = @('gamma_macros::*') + } + @{ + Name = 'macro_runtime' + Version = '0.3.0' + Deps = @(@{ Name = 'gamma_macros' }) + AllowedExternalTypes = @('gamma_macros::derive_gamma') + } + @{ + Name = 'detached_macros' + Version = '0.1.0' + ProcMacro = $true + MacroRuntime = @('detached_runtime') + } + @{ Name = 'detached_runtime'; Version = '0.1.0' } + ) + } + $script:FixWs = New-SyntheticWorkspace -Spec $spec -Path $script:FixWsRoot + + $script:FixWs.WriteFile('crates/macro_runtime/tests/ui/existing.rs', 'fn main() {}') + $script:FixWs.WriteFile('crates/macro_runtime/tests/ui/existing.stderr', 'error: old') + $script:FixWs.WriteFile('crates/macro_runtime/tests/ui/gone.rs', 'fn main() {}') + $script:FixWs.WriteFile('crates/macro_runtime/tests/ui/gone.stderr', 'error: gone') + $script:FixWs.AddCommit('add ui fixtures') + + # The version bump is the release baseline every obligation is diffed + # against, and the fixtures above predate it. + $script:FixWs.SetVersion('macro_runtime', '0.4.0') + $script:FixWs.AddCommit('bump macro_runtime to 0.4.0') + $script:FixBaselineRev = $script:FixWs.GitSha('HEAD') + + # Unreleased window: one expectation rewritten, one case newly rejected, + # one fixture deleted, one case with no recorded expectation at all. + $script:FixWs.WriteFile('crates/macro_runtime/tests/ui/existing.stderr', 'error: new') + $script:FixWs.WriteFile('crates/macro_runtime/tests/ui/reject_case.rs', 'fn main() {}') + $script:FixWs.WriteFile('crates/macro_runtime/tests/ui/reject_case.stderr', 'error: rejected') + $script:FixWs.WriteFile('crates/macro_runtime/tests/ui/plain_case.rs', 'fn main() {}') + Remove-Item -LiteralPath ( + Join-Path $script:FixWsRoot 'crates\macro_runtime\tests\ui\gone.rs' + ) + Remove-Item -LiteralPath ( + Join-Path $script:FixWsRoot 'crates\macro_runtime\tests\ui\gone.stderr' + ) + + $script:FixFacts = Invoke-ReleaseFacts -RepoRoot $script:FixWsRoot + $script:FixByFolder = @{} + foreach ($p in $script:FixFacts.packages) { $script:FixByFolder[$p.folder] = $p } + $script:MacroFixtures = @( + $script:FixByFolder['gamma_macros'].macroCompileFixtureChanges + ) + } + + It 'collects fixture changes owned by a runtime partner onto the macro' { + $script:FixByFolder['gamma_macros'].macroRuntimePartners | + Should -Contain 'macro_runtime' + @($script:MacroFixtures | ForEach-Object { $_.ownerPackage }) | + Should -Not -Contain 'gamma_macros' + @($script:MacroFixtures | ForEach-Object { $_.ownerPackage }) | + Sort-Object -Unique | + Should -Be @('macro_runtime') + @($script:MacroFixtures | ForEach-Object { $_.scopeRole }) | + Sort-Object -Unique | + Should -Be @('runtimePartner') + } + + It 'classifies added, modified and removed fixture paths' { + $byPath = @{} + foreach ($item in $script:MacroFixtures) { $byPath[$item.path] = $item } + + $byPath['crates/macro_runtime/tests/ui/existing.stderr'].status | + Should -Be 'modified' + $byPath['crates/macro_runtime/tests/ui/reject_case.rs'].status | + Should -Be 'added' + $byPath['crates/macro_runtime/tests/ui/reject_case.stderr'].status | + Should -Be 'added' + $byPath['crates/macro_runtime/tests/ui/gone.rs'].status | + Should -Be 'removed' + $byPath['crates/macro_runtime/tests/ui/plain_case.rs'].status | + Should -Be 'added' + # existing.rs itself never changed, so it is not an obligation. + $byPath.ContainsKey('crates/macro_runtime/tests/ui/existing.rs') | + Should -BeFalse + } + + It 'derives expectedResult only where a recorded expectation exists' { + $byPath = @{} + foreach ($item in $script:MacroFixtures) { $byPath[$item.path] = $item } + + $byPath['crates/macro_runtime/tests/ui/reject_case.rs'].kind | + Should -Be 'uiFixture' + $byPath['crates/macro_runtime/tests/ui/reject_case.rs'].expectedResult | + Should -Be 'fail' + $byPath['crates/macro_runtime/tests/ui/reject_case.stderr'].kind | + Should -Be 'uiExpectation' + $byPath['crates/macro_runtime/tests/ui/reject_case.stderr'].expectedResult | + Should -Be 'fail' + # No sibling expectation on either side: the outcome is not mechanically + # discoverable, so the fact refuses to guess. + $byPath['crates/macro_runtime/tests/ui/plain_case.rs'].expectedResult | + Should -BeNullOrEmpty + } + + It 'records the owner package, published flag and baseline revision' { + foreach ($item in $script:MacroFixtures) { + $item.ownerPackage | Should -Be 'macro_runtime' + $item.ownerPublished | Should -BeTrue + $item.baselineRev | Should -Be $script:FixBaselineRev + } + } + + It 'emits obligations in a deterministic ordinal order' { + $paths = @($script:MacroFixtures | ForEach-Object { $_.path }) + $sorted = [string[]]@($paths) + [Array]::Sort($sorted, [StringComparer]::Ordinal) + $paths | Should -Be $sorted + + $again = Invoke-ReleaseFacts -RepoRoot $script:FixWsRoot + $againMacro = $again.packages | Where-Object folder -eq 'gamma_macros' + @($againMacro.macroCompileFixtureChanges | ForEach-Object { $_.path }) | + Should -Be $paths + } + + It 'leaves unrelated packages without obligations' { + @($script:FixByFolder['macro_runtime'].macroCompileFixtureChanges).Count | + Should -Be 0 + @($script:FixByFolder['detached_macros'].macroCompileFixtureChanges).Count | + Should -Be 0 + } +} + +Describe 'release-facts.ps1 external dependency exposure' { + BeforeAll { + # `syn` is inherited from [workspace.dependencies] by four crates that + # differ only in what they expose, so one root-manifest edit produces + # every outcome the lane has to tell apart. + $script:ExtWsRoot = Join-Path $TestDrive 'external-ws' + $spec = @{ + ExternalDependencies = @{ + syn = '2.0.111' + serde = '1.0.200' + } + Packages = @( + @{ + Name = 'exposing_impl' + Version = '0.1.0' + Deps = @(@{ Name = 'syn'; External = $true }) + AllowedExternalTypes = @('syn::error::*') + } + @{ + Name = 'private_user' + Version = '0.1.0' + Deps = @(@{ Name = 'syn'; External = $true }) + AllowedExternalTypes = @('serde::Serialize') + } + @{ + Name = 'syn_macros' + Version = '0.1.0' + ProcMacro = $true + Deps = @(@{ Name = 'syn'; External = $true }) + } + @{ + Name = 'unknown_exposure' + Version = '0.1.0' + Deps = @(@{ Name = 'syn'; External = $true }) + } + @{ + Name = 'dev_only_user' + Version = '0.1.0' + Deps = @(@{ Name = 'syn'; External = $true; Kind = 'dev' }) + AllowedExternalTypes = @() + } + @{ + Name = 'inline_user' + Version = '0.1.0' + Deps = @(@{ Name = 'serde'; External = $true; Version = '1.0.200' }) + AllowedExternalTypes = @('serde::Serialize') + } + ) + } + $script:ExtWs = New-SyntheticWorkspace -Spec $spec -Path $script:ExtWsRoot + $script:ExtWs.AddCommit('baseline release state') + + # Unreleased window: the workspace-inherited requirement crosses a + # compatibility line, and one crate pins its own inline requirement. + $script:ExtWs.SetWorkspaceDependencyVersion('syn', '3.0.2') + $script:ExtWs.SetPackageDependencyVersion('inline_user', 'serde', '2.0.0') + + $script:ExtFacts = Invoke-ReleaseFacts -RepoRoot $script:ExtWsRoot + $script:ExtByFolder = @{} + foreach ($p in $script:ExtFacts.packages) { $script:ExtByFolder[$p.folder] = $p } + } + + It 'detects a workspace-inherited requirement change against the package baseline' { + $changes = @($script:ExtByFolder['exposing_impl'].externalDepChanges) + $changes.Count | Should -Be 1 + $changes[0].name | Should -Be 'syn' + $changes[0].baselineReq | Should -Be '^2.0.111' + $changes[0].currentReq | Should -Be '^3.0.2' + $changes[0].breaking | Should -BeTrue + $changes[0].kinds | Should -Be @('normal') + } + + It 'detects a directly declared requirement change' { + $changes = @($script:ExtByFolder['inline_user'].externalDepChanges) + $changes.Count | Should -Be 1 + $changes[0].name | Should -Be 'serde' + $changes[0].baselineReq | Should -Be '^1.0.200' + $changes[0].currentReq | Should -Be '^2.0.0' + $changes[0].breaking | Should -BeTrue + } + + It 'reports exposure only where the allowlist admits the dependency' { + $script:ExtByFolder['exposing_impl'].externalExposedDeps | + Should -Be @('syn') + @($script:ExtByFolder['private_user'].externalExposedDeps) | + Should -Not -Contain 'syn' + } + + It 'never exposes a foreign type identity through a proc macro' { + @($script:ExtByFolder['syn_macros'].externalDepChanges).Count | + Should -BeGreaterThan 0 + @($script:ExtByFolder['syn_macros'].externalExposedDeps).Count | + Should -Be 0 + } + + It 'fails closed when the crate declares no exposure metadata' { + $script:ExtByFolder['unknown_exposure'].externalExposedDeps | + Should -Be @('syn') + } + + It 'ignores dev-only external dependencies' { + @($script:ExtByFolder['dev_only_user'].externalDepChanges).Count | + Should -Be 0 + @($script:ExtByFolder['dev_only_user'].externalExposedDeps).Count | + Should -Be 0 + } + + It 'promotes a package whose only change is the inherited requirement' { + # Nothing under crates/private_user/ was touched, so without the + # promotion the crate would never reach review at all. + $script:ExtByFolder['private_user'].modifiedFileCount | Should -Be 0 + $script:ExtByFolder['private_user'].modified | Should -BeTrue + $script:ExtByFolder['private_user'].workspaceModified | Should -BeTrue + $script:ExtByFolder['private_user'].manifestDependencyScopes | + Should -Contain 'normal' + } + + It 'emits changes in a deterministic ordinal order' { + foreach ($fact in $script:ExtFacts.packages) { + $names = @($fact.externalDepChanges | ForEach-Object { $_.name }) + $sorted = [string[]]@($names) + [Array]::Sort($sorted, [StringComparer]::Ordinal) + $names | Should -Be $sorted + } + } + + It 'reports the same facts on a repeated run' { + $again = Invoke-ReleaseFacts -RepoRoot $script:ExtWsRoot + ($again.packages | ConvertTo-Json -Depth 8) | + Should -Be ($script:ExtFacts.packages | ConvertTo-Json -Depth 8) + } +} diff --git a/scripts/tests/Pester/unit/releasing/ReleasePlan.Tests.ps1 b/scripts/tests/Pester/unit/releasing/ReleasePlan.Tests.ps1 new file mode 100644 index 000000000..78e3628e4 --- /dev/null +++ b/scripts/tests/Pester/unit/releasing/ReleasePlan.Tests.ps1 @@ -0,0 +1,3010 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + . (Join-Path $PSScriptRoot '..\..\_common\TestHelpers.ps1') + . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\releasing.ps1') + + $script:Resolver = Join-Path ( + Get-OxiRepoRoot + ) '.github\skills\release-packages\scripts\resolve-plan.ps1' + + function New-ReleaseFact { + param( + [Parameter(Mandatory = $true)][string]$Name, + [string]$Version = '1.0.0', + [string[]]$Deps = @(), + [string[]]$ExposedDeps = @(), + [string[]]$MacroPublicDeps = @(), + [string[]]$MacroImplementationClosure = @(), + [string[]]$MacroRuntimePartners = @(), + [bool]$ExposureUnknown = $false, + [bool]$Published = $true, + [bool]$EverReleased = $true, + [bool]$ProcMacroOnly = $false, + [bool]$Modified = $true, + [string[]]$ModifiedFiles = @(), + [string[]]$ManifestDependencyScopes = @(), + [bool]$ManifestOtherChanged = $false, + [object[]]$MacroCompileFixtureChanges = @(), + [object[]]$ExternalDepChanges = @(), + [string[]]$ExternalExposedDeps = @(), + [bool]$RustImplementationChanged = $true, + [bool]$DocCommentChanged = $false, + [bool]$WorkspaceModified = $Modified + ) + + if ($Modified -and $ModifiedFiles.Count -eq 0) { + $ModifiedFiles = @("crates/$Name/src/lib.rs") + } + + return [ordered]@{ + folder = $Name + name = $Name + version = $Version + published = $Published + procMacroOnly = $ProcMacroOnly + hasLibraryTarget = -not $ProcMacroOnly + deps = @($Deps) + exposedDeps = @($ExposedDeps) + macroPublicDeps = @($MacroPublicDeps) + macroImplementationClosure = @($MacroImplementationClosure) + macroRuntimePartners = @($MacroRuntimePartners) + exposureUnknown = $ExposureUnknown + baselineSha = if ($EverReleased) { '0123456789012345678901234567890123456789' } else { $null } + hasBaseline = $EverReleased + everReleased = $EverReleased + modified = $Modified + modifiedFiles = @($ModifiedFiles) + modifiedFileCount = $ModifiedFiles.Count + manifestDependencyScopes = @($ManifestDependencyScopes) + manifestOtherChanged = $ManifestOtherChanged + rustImplementationChanged = $RustImplementationChanged + docCommentChanged = $DocCommentChanged + macroCompileFixtureChanges = @($MacroCompileFixtureChanges) + externalDepChanges = @($ExternalDepChanges) + externalExposedDeps = @($ExternalExposedDeps) + workspaceModified = $WorkspaceModified + } + } + + # A fact-side external dependency requirement change, shaped exactly as + # release-facts.ps1 emits it. + function New-ExternalDepChange { + param( + [Parameter(Mandatory = $true)][string]$Name, + [AllowNull()][string]$BaselineReq = '^2.0.111', + [AllowNull()][string]$CurrentReq = '^3.0.2', + [string[]]$Kinds = @('normal'), + [bool]$Breaking = $true, + [string]$BaselineRev = '0123456789012345678901234567890123456789' + ) + + return [ordered]@{ + name = $Name + baselineReq = $BaselineReq + currentReq = $CurrentReq + kinds = @($Kinds) + breaking = $Breaking + baselineRev = $BaselineRev + } + } + + # A fact-side compile-fixture obligation, shaped exactly as release-facts.ps1 + # emits it. + function New-CompileFixtureChange { + param( + [Parameter(Mandatory = $true)][string]$OwnerPackage, + [Parameter(Mandatory = $true)][string]$Path, + [ValidateSet('added', 'modified', 'removed')] + [string]$Status = 'added', + [ValidateSet('self', 'runtimePartner', 'implementationClosure')] + [string]$ScopeRole = 'runtimePartner', + [bool]$OwnerPublished = $true, + [AllowNull()][string]$ExpectedResult = 'fail', + [string]$BaselineRev = '0123456789012345678901234567890123456789' + ) + + return [ordered]@{ + ownerPackage = $OwnerPackage + ownerPublished = $OwnerPublished + path = $Path + kind = if ($Path.EndsWith('.rs')) { 'uiFixture' } else { 'uiExpectation' } + status = $Status + expectedResult = $ExpectedResult + baselineRev = $BaselineRev + scopeRole = $ScopeRole + } + } + + # A contract-side measurement of one fixture, keyed to the obligation above. + function New-CompileEvidence { + param( + [Parameter(Mandatory = $true)][string]$OwnerPackage, + [Parameter(Mandatory = $true)][string]$Path, + [ValidateSet('pass', 'fail')][string]$Baseline = 'pass', + [ValidateSet('pass', 'fail')][string]$Current = 'pass', + [string]$BaselineRev = '0123456789012345678901234567890123456789', + [string]$CurrentRev = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd' + ) + + return @{ + ownerPackage = $OwnerPackage + path = $Path + baseline = @{ + result = $Baseline + revision = $BaselineRev + exitCode = if ($Baseline -eq 'pass') { 0 } else { 101 } + } + current = @{ + result = $Current + revision = $CurrentRev + exitCode = if ($Current -eq 'pass') { 0 } else { 101 } + } + } + } + + function New-MacroContract { + param( + [ValidateSet('compatible', 'nonbreaking', 'breaking')] + [string]$Verdict = 'compatible', + [string[]]$ReviewedPackages = @('macros'), + [string[]]$Evidence = @('Reviewed macro exports, compile fixtures, and generated API.'), + [object[]]$CompileEvidence = @() + ) + + $contract = @{ + verdict = $Verdict + reviewedPackages = @($ReviewedPackages) + channels = @{ + exportedMacros = 'unchanged' + acceptedSyntax = 'unchanged' + compileBehavior = 'unchanged' + generatedApi = 'unchanged' + generatedRuntimePaths = 'unchanged' + hygiene = 'unchanged' + } + evidence = @($Evidence) + } + if ($CompileEvidence.Count -gt 0) { + $contract['compileEvidence'] = @($CompileEvidence) + } + return $contract + } + + function Invoke-ReleasePlan { + param( + [Parameter(Mandatory = $true)][object[]]$Facts, + [Parameter(Mandatory = $true)][hashtable]$Request, + [int]$SchemaVersion = 5 + ) + + $caseDir = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $caseDir | Out-Null + $factsPath = Join-Path $caseDir 'facts.json' + $requestPath = Join-Path $caseDir 'request.json' + [ordered]@{ schemaVersion = $SchemaVersion; packages = @($Facts) } | + ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $factsPath -Encoding utf8 + $Request | + ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $requestPath -Encoding utf8 + + $json = & $script:Resolver ` + -FactsPath $factsPath ` + -RequestPath $requestPath + return ($json | ConvertFrom-Json) + } + + function New-RegressionEvidence { + param( + [string]$Kind = 'consumer-runtime', + [string]$Probe = 'cargo test -p package --test regression', + [string]$Baseline = 'fail', + [string]$Current = 'pass', + [string]$BaselineRevision = 'baseline-sha', + [string]$CurrentRevision = 'worktree', + [object]$BaselineExitCode, + [object]$CurrentExitCode + ) + + $baselineExit = if ($PSBoundParameters.ContainsKey('BaselineExitCode')) { + $BaselineExitCode + } elseif ($Baseline -eq 'pass') { 0 } else { 101 } + $currentExit = if ($PSBoundParameters.ContainsKey('CurrentExitCode')) { + $CurrentExitCode + } elseif ($Current -eq 'pass') { 0 } else { 101 } + + return @{ + kind = $Kind + probe = $Probe + baseline = @{ + revision = $BaselineRevision + result = $Baseline + exitCode = $baselineExit + } + current = @{ + revision = $CurrentRevision + result = $Current + exitCode = $currentExit + } + } + } + + function New-SelectionDecision { + param( + [ValidateSet('accept', 'decline')] + [string]$Decision = 'accept', + [string]$Reason = 'behavior-fix', + [AllowNull()][AllowEmptyCollection()][object[]]$RegressionEvidence + ) + + $value = @{ + decision = $Decision + reason = $Reason + evidence = @('Reviewed the package diff from its release baseline.') + } + if ($PSBoundParameters.ContainsKey('RegressionEvidence')) { + $value.regressionEvidence = @($RegressionEvidence) + } elseif ($Reason -eq 'behavior-fix') { + $value.regressionEvidence = @(New-RegressionEvidence) + } + return $value + } +} + +Describe 'resolve-plan.ps1 version arithmetic' { + BeforeDiscovery { + $cases = @( + @{ Name = 'stable breaking'; Version = '1.2.3'; Change = 'breaking'; Expected = '2.0.0' } + @{ Name = 'stable nonbreaking'; Version = '1.2.3'; Change = 'nonbreaking'; Expected = '1.3.0' } + @{ Name = 'stable patch'; Version = '1.2.3'; Change = 'patch'; Expected = '1.2.4' } + @{ Name = '0.x breaking'; Version = '0.4.2'; Change = 'breaking'; Expected = '0.5.0' } + @{ Name = '0.x nonbreaking'; Version = '0.4.2'; Change = 'nonbreaking'; Expected = '0.4.3' } + @{ Name = '0.x patch'; Version = '0.4.2'; Change = 'patch'; Expected = '0.4.3' } + @{ Name = '0.0.x breaking'; Version = '0.0.5'; Change = 'breaking'; Expected = '0.0.6' } + @{ Name = '0.0.x nonbreaking'; Version = '0.0.5'; Change = 'nonbreaking'; Expected = '0.0.6' } + @{ Name = '0.0.x patch'; Version = '0.0.5'; Change = 'patch'; Expected = '0.0.6' } + ) + } + + It '' -ForEach $cases { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version $Version) ` + -Request @{ + mode = 'targeted' + tokens = @("package@$Change") + classifications = @{ package = 'patch' } + } + + $plan.releases.Count | Should -Be 1 + $plan.releases[0].to | Should -Be $Expected + $plan.releases[0].changeType | Should -Be $Change + } + + It 'ships a first-ever release at its declared version' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version '0.1.0' -EverReleased $false) ` + -Request @{ mode = 'targeted'; tokens = @('package'); classifications = @{} } + + $plan.releases[0].from | Should -Be '0.1.0' + $plan.releases[0].to | Should -Be '0.1.0' + } + + It 'honors an explicit pin for a first-ever release' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version '0.1.0' -EverReleased $false) ` + -Request @{ mode = 'targeted'; tokens = @('package@1.0.0'); classifications = @{} } + + $plan.releases[0].to | Should -Be '1.0.0' + } + + It 'uses an objective classification stronger than the requested lower bound' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version '1.2.3') ` + -Request @{ + mode = 'targeted' + tokens = @('package@patch') + classifications = @{ package = 'breaking' } + } + + $plan.releases[0].to | Should -Be '2.0.0' + $plan.releases[0].changeType | Should -Be 'breaking' + } +} + +Describe 'resolve-plan.ps1 cascades' { + It 'cascades patch through a non-exposing linear chain' { + $facts = @( + New-ReleaseFact -Name bottom + New-ReleaseFact -Name middle -Deps bottom + New-ReleaseFact -Name top -Deps middle + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('bottom@patch') + classifications = @{ bottom = 'patch'; middle = 'patch'; top = 'patch' } + } + + @($plan.releases.folder) | Should -Be @('bottom', 'middle', 'top') + @($plan.releases.to) | Should -Be @('1.0.1', '1.0.1', '1.0.1') + @($plan.releases.changeType) | Should -Be @('patch', 'patch', 'patch') + } + + It 'propagates a breaking release across exposure edges to a fixed point' { + $facts = @( + New-ReleaseFact -Name bottom + New-ReleaseFact -Name middle -Version '0.3.2' -Deps bottom -ExposedDeps bottom + New-ReleaseFact -Name top -Version '2.4.0' -Deps middle -ExposedDeps middle + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('bottom@breaking') + classifications = @{ bottom = 'patch'; middle = 'patch'; top = 'patch' } + } + + @($plan.releases.to) | Should -Be @('2.0.0', '0.4.0', '3.0.0') + @($plan.releases.changeType) | Should -Be @('breaking', 'breaking', 'breaking') + $plan.releases[1].cascadeReasons[0].breaking | Should -BeTrue + $plan.releases[2].cascadeReasons[0].breaking | Should -BeTrue + } + + It 'propagates a break to an indirect dependent that exposes the defining crate' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name relay -Deps core + New-ReleaseFact -Name facade -Deps relay -ExposedDeps core + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'patch'; relay = 'patch'; facade = 'patch' } + } + + @($plan.releases.folder) | Should -Be @('core', 'relay', 'facade') + ($plan.releases | Where-Object folder -eq relay).changeType | Should -Be 'patch' + $facade = $plan.releases | Where-Object folder -eq facade + $facade.changeType | Should -Be 'breaking' + $facade.cascadeReasons[0].target | Should -Be 'core' + $facade.cascadeReasons[0].breaking | Should -BeTrue + } + + It 'treats every 0.0.z bump as breaking when the dependency is exposed' { + $facts = @( + New-ReleaseFact -Name unstable -Version '0.0.5' + New-ReleaseFact -Name consumer -Version '1.4.0' -Deps unstable -ExposedDeps unstable + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('unstable@patch') + classifications = @{ unstable = 'patch'; consumer = 'patch' } + } + + $plan.releases[0].to | Should -Be '0.0.6' + $plan.releases[1].to | Should -Be '2.0.0' + $plan.releases[1].cascadeReasons[0].breaking | Should -BeTrue + } + + It 'merges diamond reasons and applies the strongest path once' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name left -Deps core -ExposedDeps core + New-ReleaseFact -Name right -Deps core + New-ReleaseFact -Name top -Deps @('left', 'right') -ExposedDeps left + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'patch'; left = 'patch'; right = 'patch'; top = 'patch' } + } + + @($plan.releases.folder) | Should -Be @('core', 'left', 'right', 'top') + ($plan.releases | Where-Object folder -eq left).changeType | Should -Be 'breaking' + ($plan.releases | Where-Object folder -eq right).changeType | Should -Be 'patch' + $top = $plan.releases | Where-Object folder -eq top + $top.changeType | Should -Be 'breaking' + @($top.cascadeReasons).Count | Should -Be 2 + @($top.cascadeReasons.target) | Should -Be @('left', 'right') + } + + It 'skips unpublished dependents' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name private_consumer -Deps core -Published $false + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@patch') + classifications = @{ core = 'patch' } + } + + @($plan.releases.folder) | Should -Be @('core') + } + + It 'blocks a breaking implementation dependency until the macro contract is reviewed' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name macros -Version '0.4.0' -Deps core ` + -MacroImplementationClosure core -ProcMacroOnly $true + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'patch' } + } + + $plan.status | Should -Be 'blocked' + $plan.releases.Count | Should -Be 0 + $plan.ambiguities[0].kind | Should -Be 'macroContractUnreviewed' + @($plan.ambiguities[0].reviewScope) | Should -Be @('core', 'macros') + } + + It 'keeps an implementation break at a proc-macro patch floor when its contract is compatible' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name macros -Version '0.4.0' -Deps core ` + -MacroImplementationClosure core -ProcMacroOnly $true + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'patch' } + macroContracts = @{ + macros = New-MacroContract -ReviewedPackages @('core', 'macros') + } + } + + $macros = $plan.releases | Where-Object folder -eq macros + $macros.to | Should -Be '0.4.1' + $macros.changeType | Should -Be 'patch' + $macros.manualReview | Should -BeTrue + $macros.contractBreaking | Should -BeFalse + $macros.cascadeReasons[0].edgeClass | Should -Be 'macroImplementation' + $macros.cascadeReasons[0].judgment | Should -Be 'contractCompatible' + } + + It 'blocks an incomplete macro review scope' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name macros -Version '0.4.0' -Deps core ` + -MacroImplementationClosure core -ProcMacroOnly $true + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'patch' } + macroContracts = @{ + macros = New-MacroContract -ReviewedPackages @('macros') + } + } + + $plan.status | Should -Be 'blocked' + $plan.ambiguities[0].kind | Should -Be 'macroContractIncomplete' + @($plan.ambiguities[0].reviewScope) | Should -Contain 'core' + } + + It 'blocks when an unpublished implementation helper changed' { + $facts = @( + New-ReleaseFact -Name helper -Published $false -Modified $false ` + -WorkspaceModified $true + New-ReleaseFact -Name macros -ProcMacroOnly $true -Modified $false ` + -WorkspaceModified $false -MacroImplementationClosure helper + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{} + } + + $plan.status | Should -Be 'blocked' + @($plan.ambiguities[0].reviewScope) | Should -Be @('helper', 'macros') + } + + It 'reviews an unpublished helper without releasing it' { + $facts = @( + New-ReleaseFact -Name helper -Published $false -Modified $false ` + -WorkspaceModified $true + New-ReleaseFact -Name macros -ProcMacroOnly $true -Modified $false ` + -WorkspaceModified $false -MacroImplementationClosure helper + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{} + macroContracts = @{ + macros = New-MacroContract -ReviewedPackages @('helper', 'macros') + } + } + + $plan.status | Should -Be 'resolved' + @($plan.releases.folder) | Should -Be @('macros') + } + + It 'blocks changed generated-runtime paths when no partner can be inferred' { + $contract = New-MacroContract + $contract.channels.generatedRuntimePaths = 'changed' + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name macros -Version '1.0.0' ` + -ProcMacroOnly $true + ) ` + -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{} + macroContracts = @{ macros = $contract } + } + + $plan.status | Should -Be 'blocked' + $plan.ambiguities[0].kind | Should -Be 'macroRuntimeUnknown' + } + + It 'propagates a reviewed breaking macro contract through a public macro edge' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name macros -Version '0.4.0' -Deps core ` + -MacroImplementationClosure core -ProcMacroOnly $true + New-ReleaseFact -Name runtime -Version '0.4.0' -Deps macros ` + -MacroPublicDeps macros + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'patch'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict breaking ` + -ReviewedPackages @('core', 'macros') + } + } + + $macros = $plan.releases | Where-Object folder -eq macros + $runtime = $plan.releases | Where-Object folder -eq runtime + $macros.to | Should -Be '0.5.0' + $macros.contractBreaking | Should -BeTrue + $runtime.to | Should -Be '0.5.0' + $runtime.cascadeReasons[0].edgeClass | Should -Be 'macroPublic' + $runtime.cascadeReasons[0].breaking | Should -BeTrue + } + + It 'does not turn a compatible macro major pin into a public contract break' { + $facts = @( + New-ReleaseFact -Name macros -Version '0.4.0' -ProcMacroOnly $true + New-ReleaseFact -Name runtime -Version '0.4.0' -Deps macros ` + -MacroPublicDeps macros + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macros@0.5.0') + classifications = @{ runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract + } + } + + $macros = $plan.releases | Where-Object folder -eq macros + $runtime = $plan.releases | Where-Object folder -eq runtime + $macros.changeType | Should -Be 'breaking' + $macros.contractBreaking | Should -BeFalse + $runtime.changeType | Should -Be 'patch' + $runtime.cascadeReasons[0].edgeClass | Should -Be 'macroPublic' + $runtime.cascadeReasons[0].breaking | Should -BeFalse + } + + It 'requires a macro contract for an unchanged exact version pin' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name macros -Version '1.0.0' ` + -ProcMacroOnly $true -Modified $false + ) ` + -Request @{ + mode = 'targeted' + tokens = @('macros@2.0.0') + classifications = @{} + } + + $plan.status | Should -Be 'blocked' + $plan.ambiguities[0].kind | Should -Be 'macroContractUnreviewed' + } + + It 'does not propagate Cargo breaking arithmetic for a compatible 0.0 proc macro' { + $facts = @( + New-ReleaseFact -Name macros -Version '0.0.5' -ProcMacroOnly $true + New-ReleaseFact -Name runtime -Version '1.0.0' -Deps macros ` + -MacroPublicDeps macros + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract + } + } + + ($plan.releases | Where-Object folder -eq macros).to | + Should -Be '0.0.6' + ($plan.releases | Where-Object folder -eq runtime).changeType | + Should -Be 'patch' + } + + It 'keeps an internally used proc macro at a patch floor even when its contract breaks' { + $facts = @( + New-ReleaseFact -Name macros -Version '1.0.0' -ProcMacroOnly $true + New-ReleaseFact -Name internal_user -Version '1.0.0' -Deps macros + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macros@breaking') + classifications = @{ internal_user = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict breaking + } + } + + $internal = $plan.releases | Where-Object folder -eq internal_user + $internal.changeType | Should -Be 'patch' + $internal.cascadeReasons[0].edgeClass | Should -Be 'macroPrivate' + $internal.cascadeReasons[0].breaking | Should -BeFalse + } + + It 'requires a contract when a cascade-reached macro is classified above patch' { + $facts = @( + New-ReleaseFact -Name core -Modified $false + New-ReleaseFact -Name macros -Deps core -ProcMacroOnly $true ` + -Modified $false + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@patch') + classifications = @{ core = 'patch'; macros = 'breaking' } + } + + $plan.status | Should -Be 'blocked' + $plan.ambiguities[0].kind | Should -Be 'macroContractUnreviewed' + } + + It 'reviews generated-runtime coupling and releases the macro only for a changed contract' { + $facts = @( + New-ReleaseFact -Name macros -Version '1.0.0' -ProcMacroOnly $true ` + -MacroRuntimePartners runtime + New-ReleaseFact -Name runtime -Version '1.0.0' -Deps macros + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('runtime@breaking') + classifications = @{ runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict nonbreaking ` + -ReviewedPackages @('macros', 'runtime') + } + } + + $macros = $plan.releases | Where-Object folder -eq macros + $macros.changeType | Should -Be 'nonbreaking' + $macros.cascadeReasons[0].edgeClass | Should -Be 'macroRuntime' + } + + It 'uses exposureUnknown as a conservative breaking edge' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name consumer -Version '1.0.0' -Deps core -ExposureUnknown $true + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'patch'; consumer = 'patch' } + } + + ($plan.releases | Where-Object folder -eq consumer).to | Should -Be '2.0.0' + } + + It 'does not cascade into a package that has never been released' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name future -Deps core -EverReleased $false + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@patch') + classifications = @{ core = 'patch' } + } + + @($plan.releases.folder) | Should -Be @('core') + } + + It 'deduplicates dependency edges while ordering' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name consumer -Deps @('core', 'core') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@patch') + classifications = @{ core = 'patch'; consumer = 'patch' } + } + + @($plan.releases.folder) | Should -Be @('core', 'consumer') + } +} + +Describe 'resolve-plan.ps1 macro compile evidence' { + BeforeAll { + # The run-8 shape: a proc macro that newly rejects an input it used to + # accept, where the compile fixture proving it lives in the runtime + # partner rather than in the macro crate. + function New-RejectionFacts { + param([bool]$Modified = $true) + + return @( + New-ReleaseFact -Name macros -Version '0.4.0' -ProcMacroOnly $true ` + -MacroRuntimePartners @('runtime') -Modified $Modified ` + -MacroCompileFixtureChanges @( + New-CompileFixtureChange -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.rs' -Status 'added' + New-CompileFixtureChange -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.stderr' -Status 'added' + ) + New-ReleaseFact -Name runtime -Version '0.4.0' -Deps macros ` + -MacroPublicDeps macros ` + -ModifiedFiles @('crates/runtime/tests/ui/reject_case.rs') + ) + } + } + + It 'blocks a compatible verdict contradicted by a pass to fail fixture' { + $plan = Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ macros = 'patch'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.rs' ` + -Baseline 'pass' -Current 'fail' + ) + } + } + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'macroVerdictUnderclassified' + $ambiguity | Should -Not -BeNullOrEmpty + $ambiguity.package | Should -Be 'macros' + $ambiguity.declaredVerdict | Should -Be 'compatible' + $ambiguity.derivedVerdict | Should -Be 'breaking' + @($ambiguity.decidingFixtures) | + Should -Contain 'crates/runtime/tests/ui/reject_case.rs' + } + + It 'accepts a breaking verdict backed by the same evidence and cascades it' { + $plan = Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'targeted' + tokens = @('macros@breaking') + classifications = @{ macros = 'breaking'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'breaking' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.rs' ` + -Baseline 'pass' -Current 'fail' + ) + } + } + + $plan.status | Should -Be 'resolved' + $macros = $plan.releases | Where-Object folder -eq macros + $macros.to | Should -Be '0.5.0' + ($plan.macroContracts | Where-Object package -eq macros).derivedVerdict | + Should -Be 'breaking' + # The macro is a public dependency of its runtime facade, so the break + # has to reach the facade too. + $runtime = $plan.releases | Where-Object folder -eq runtime + $runtime.changeType | Should -Be 'breaking' + } + + It 'lets a compatible verdict stand when the fixture failed before and after' { + $plan = Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ macros = 'patch'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.rs' ` + -Baseline 'fail' -Current 'fail' + ) + } + } + + $plan.status | Should -Be 'resolved' + $macros = $plan.releases | Where-Object folder -eq macros + $macros.to | Should -Be '0.4.1' + $macros.changeType | Should -Be 'patch' + ($plan.macroContracts | Where-Object package -eq macros).derivedVerdict | + Should -Be 'compatible' + } + + It 'treats a fail to pass fixture as a non-breaking floor' { + $plan = Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ macros = 'patch'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.rs' ` + -Baseline 'fail' -Current 'pass' + ) + } + } + + $plan.status | Should -Be 'blocked' + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'macroVerdictUnderclassified' + $ambiguity.derivedVerdict | Should -Be 'nonbreaking' + } + + It 'blocks a contract that leaves a changed fixture unmeasured' { + $plan = Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ macros = 'patch'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('macros', 'runtime') + } + } + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'macroCompileFixtureUnevidenced' + $ambiguity | Should -Not -BeNullOrEmpty + $ambiguity.requiredInput | Should -Be 'macroContracts.macros.compileEvidence' + @($ambiguity.fixtures) | + Should -Contain 'crates/runtime/tests/ui/reject_case.rs' + } + + It 'discharges an expectation file through its compiled sibling' { + $plan = Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'targeted' + tokens = @('macros@breaking') + classifications = @{ macros = 'breaking'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'breaking' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.stderr' ` + -Baseline 'pass' -Current 'fail' + ) + } + } + + # One measurement of the fixture answers for both the .rs case and its + # recorded .stderr expectation. + $plan.status | Should -Be 'resolved' + ($plan.macroContracts | Where-Object package -eq macros).derivedVerdict | + Should -Be 'breaking' + } + + It 'blocks evidence that does not record a usable measurement' { + $plan = Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ macros = 'patch'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @( + @{ + ownerPackage = 'runtime' + path = 'crates/runtime/tests/ui/reject_case.rs' + baseline = @{ result = 'pass'; revision = 'abc123'; exitCode = 0 } + current = @{ result = 'unknown'; revision = ''; exitCode = $null } + } + ) + } + } + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + ($plan.ambiguities | Where-Object kind -eq 'macroCompileEvidenceInconclusive') | + Should -Not -BeNullOrEmpty + } + + It 'rejects evidence that names no fixture' { + { + Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ macros = 'patch'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @(@{ path = 'crates/runtime/tests/ui/reject_case.rs' }) + } + } + } | Should -Throw '*must name ownerPackage and path*' + } + + It 'refuses a behavior-fix selection reason for a derived break' { + { + Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'changed' + tokens = @('macros@breaking', 'runtime@patch') + selectionDecisions = @{ + macros = New-SelectionDecision -Reason 'behavior-fix' + runtime = New-SelectionDecision -Reason 'behavior-fix' + } + classifications = @{ macros = 'breaking'; runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'breaking' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.rs' ` + -Baseline 'pass' -Current 'fail' + ) + } + } + } | Should -Throw "*conflicts with the 'breaking' macro contract*" + } + + It 'refuses to decline a proc macro whose fixtures prove a break' { + { + Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'changed' + tokens = @('runtime@patch') + selectionDecisions = @{ + macros = New-SelectionDecision -Decision 'decline' -Reason 'test-only' + runtime = New-SelectionDecision -Reason 'behavior-fix' + } + classifications = @{ runtime = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'breaking' ` + -ReviewedPackages @('macros', 'runtime') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.rs' ` + -Baseline 'pass' -Current 'fail' + ) + } + } + } | Should -Throw '*declines a package whose compile evidence*' + } + + It 'blocks a declined proc macro that never explains its fixture changes' { + $plan = Invoke-ReleasePlan -Facts (New-RejectionFacts) -Request @{ + mode = 'changed' + tokens = @('runtime@patch') + selectionDecisions = @{ + macros = New-SelectionDecision -Decision 'decline' -Reason 'test-only' + runtime = New-SelectionDecision -Reason 'behavior-fix' + } + classifications = @{ runtime = 'patch' } + } + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + ($plan.ambiguities | Where-Object kind -eq 'macroContractUnreviewed') | + Should -Not -BeNullOrEmpty + } + + It 'reports but does not derive a floor from a published dependency fixture' { + # The fixture belongs to a published implementation dependency, which + # carries its own classification; it must not silently reclassify every + # macro that merely depends on that crate. + $facts = @( + New-ReleaseFact -Name helper -Version '0.4.0' ` + -ModifiedFiles @('crates/helper/tests/ui/reject_case.rs') + New-ReleaseFact -Name macros -Version '0.4.0' -Deps helper ` + -ProcMacroOnly $true -MacroImplementationClosure helper ` + -MacroCompileFixtureChanges @( + New-CompileFixtureChange -OwnerPackage 'helper' ` + -Path 'crates/helper/tests/ui/reject_case.rs' -Status 'added' ` + -ScopeRole 'implementationClosure' -OwnerPublished $true + ) + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('helper@patch') + classifications = @{ helper = 'patch'; macros = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('helper', 'macros') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'helper' ` + -Path 'crates/helper/tests/ui/reject_case.rs' ` + -Baseline 'pass' -Current 'fail' + ) + } + } + + $plan.status | Should -Be 'resolved' + ($plan.macroContracts | Where-Object package -eq macros).derivedVerdict | + Should -Be 'compatible' + } + + It 'derives a floor from an unpublished helper fixture' { + # An unpublished helper has no release identity of its own, so its + # fixtures can only be speaking about the macro that consumes it. + $facts = @( + New-ReleaseFact -Name helper -Version '0.4.0' -Published $false ` + -ModifiedFiles @('crates/helper/tests/ui/reject_case.rs') + New-ReleaseFact -Name macros -Version '0.4.0' -Deps helper ` + -ProcMacroOnly $true -MacroImplementationClosure helper ` + -MacroCompileFixtureChanges @( + New-CompileFixtureChange -OwnerPackage 'helper' ` + -Path 'crates/helper/tests/ui/reject_case.rs' -Status 'added' ` + -ScopeRole 'implementationClosure' -OwnerPublished $false + ) + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ macros = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('helper', 'macros') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'helper' ` + -Path 'crates/helper/tests/ui/reject_case.rs' ` + -Baseline 'pass' -Current 'fail' + ) + } + } + + $plan.status | Should -Be 'blocked' + ($plan.ambiguities | Where-Object kind -eq 'macroVerdictUnderclassified').derivedVerdict | + Should -Be 'breaking' + } + + It 'raises the floor from evidence for a fixture the facts did not flag' { + $facts = @( + New-ReleaseFact -Name macros -Version '0.4.0' -ProcMacroOnly $true ` + -MacroRuntimePartners @('runtime') + New-ReleaseFact -Name runtime -Version '0.4.0' -Deps macros ` + -MacroPublicDeps macros -Modified $false + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{ macros = 'patch' } + macroContracts = @{ + macros = New-MacroContract -Verdict 'compatible' ` + -ReviewedPackages @('macros') ` + -CompileEvidence @( + New-CompileEvidence -OwnerPackage 'runtime' ` + -Path 'crates/runtime/tests/ui/reject_case.rs' ` + -Baseline 'pass' -Current 'fail' + ) + } + } + + $plan.status | Should -Be 'blocked' + ($plan.ambiguities | Where-Object kind -eq 'macroVerdictUnderclassified').derivedVerdict | + Should -Be 'breaking' + } +} + +Describe 'resolve-plan.ps1 breaking selection evidence' { + It 'blocks a breaking selection whose own objective classification is patch' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name facade) ` + -Request @{ + mode = 'changed' + tokens = @('facade@breaking') + selectionDecisions = @{ + facade = New-SelectionDecision -Reason breaking + } + classifications = @{ facade = 'patch' } + } + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'breakingSelectionUnderclassified' + $ambiguity.package | Should -Be 'facade' + $ambiguity.objectiveClassification | Should -Be 'compatible' + } + + It 'accepts a breaking selection supported by its own classification' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'changed' + tokens = @('package@breaking') + selectionDecisions = @{ + package = New-SelectionDecision -Reason breaking + } + classifications = @{ package = 'breaking' } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].changeType | Should -Be 'breaking' + } +} + +Describe 'resolve-plan.ps1 decline-reason precedence' { + It 'rejects generated-artifact-only when a Cargo.toml also changed' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name pkg ` + -ModifiedFiles @('crates/pkg/Cargo.toml', 'crates/pkg/README.md')) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + pkg = New-SelectionDecision -Decision decline -Reason generated-artifact-only + } + classifications = @{ pkg = 'patch' } + } + } | Should -Throw "*only this crate's generated README.md or CHANGELOG.md*" + } + + It 'accepts generated-artifact-only when only generated files changed' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name pkg ` + -ModifiedFiles @('crates/pkg/README.md', 'crates/pkg/CHANGELOG.md')) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + pkg = New-SelectionDecision -Decision decline -Reason generated-artifact-only + } + classifications = @{ pkg = 'patch' } + } + + $plan.status | Should -Be 'resolved' + $plan.selectionDecisions[0].reason | Should -Be 'generated-artifact-only' + } + + It 'rejects release-metadata-only when only a generated file changed' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name pkg ` + -ModifiedFiles @('crates/pkg/README.md')) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + pkg = New-SelectionDecision -Decision decline -Reason release-metadata-only + } + classifications = @{ pkg = 'patch' } + } + } | Should -Throw "*use 'generated-artifact-only'*" + } + + It 'rejects generated-artifact-only for an out-of-package README path' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name pkg ` + -ModifiedFiles @('crates/other/README.md')) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + pkg = New-SelectionDecision -Decision decline -Reason generated-artifact-only + } + classifications = @{ pkg = 'patch' } + } + } | Should -Throw "*only this crate's generated README.md or CHANGELOG.md*" + } +} + +Describe 'resolve-plan.ps1 review-scope normalization' { + It 'emits the computed review scope, not a model-supplied superset' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name macros -Version '0.4.0' -Deps core ` + -MacroImplementationClosure core -MacroRuntimePartners runtime ` + -ProcMacroOnly $true + New-ReleaseFact -Name runtime -Modified $false -WorkspaceModified $false + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'patch' } + macroContracts = @{ + macros = New-MacroContract -ReviewedPackages @('core', 'macros', 'runtime') + } + } + + $plan.status | Should -Be 'resolved' + $macros = $plan.macroContracts | Where-Object package -eq macros + # 'runtime' is an unmodified partner: not in the required scope, so the + # emitted scope drops it even though the model listed it. + @($macros.reviewed) | Should -Be @('core', 'macros') + } +} + +Describe 'resolve-plan.ps1 own-diff classification floor' { + It 'blocks a breaking classification when only doc comments changed' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name facade ` + -RustImplementationChanged $false -DocCommentChanged $true) ` + -Request @{ + mode = 'changed' + tokens = @('facade@breaking') + selectionDecisions = @{ + facade = New-SelectionDecision -Reason authored-doc-fix + } + classifications = @{ facade = 'breaking' } + } + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'ownClassificationUnsupported' + $ambiguity.package | Should -Be 'facade' + $ambiguity.requiredInput | Should -Be 'classifications.facade' + } + + It 'blocks a nonbreaking classification when only doc comments changed' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name facade ` + -RustImplementationChanged $false -DocCommentChanged $true) ` + -Request @{ + mode = 'changed' + tokens = @('facade@nonbreaking') + selectionDecisions = @{ + facade = New-SelectionDecision -Reason authored-doc-fix + } + classifications = @{ facade = 'nonbreaking' } + } + + $plan.status | Should -Be 'blocked' + @($plan.ambiguities | Where-Object kind -eq 'ownClassificationUnsupported') | + Should -Not -BeNullOrEmpty + } + + It 'allows an authored-doc-fix patch when only doc comments changed' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name facade ` + -RustImplementationChanged $false -DocCommentChanged $true) ` + -Request @{ + mode = 'changed' + tokens = @('facade') + selectionDecisions = @{ + facade = New-SelectionDecision -Reason authored-doc-fix + } + classifications = @{ facade = 'patch' } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].changeType | Should -Be 'patch' + } + + It 'rejects declining a doc-only authored change as internal-only' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name facade ` + -RustImplementationChanged $false -DocCommentChanged $true) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + facade = New-SelectionDecision -Decision decline -Reason internal-only + } + classifications = @{ facade = 'patch' } + } + } | Should -Throw "*must be accepted as 'authored-doc-fix'*" + } + + It 'rejects pairing authored-doc-fix with a runtime-manifest change' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name pkg ` + -RustImplementationChanged $false -DocCommentChanged $true ` + -ModifiedFiles @('crates/pkg/src/lib.rs', 'crates/pkg/Cargo.toml') ` + -ManifestDependencyScopes @('normal')) ` + -Request @{ + mode = 'changed' + tokens = @('pkg') + selectionDecisions = @{ + pkg = New-SelectionDecision -Reason authored-doc-fix + } + classifications = @{ pkg = 'patch' } + } + } | Should -Throw "*use 'runtime-manifest-change'*" + } + + It 'allows internal-only for a non-doc comment or whitespace source edit' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name facade ` + -RustImplementationChanged $false -DocCommentChanged $false) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + facade = New-SelectionDecision -Decision decline -Reason internal-only + } + classifications = @{ facade = 'patch' } + } + + $plan.status | Should -Be 'resolved' + @($plan.releases).Count | Should -Be 0 + } + + It 'does not force authored-doc-fix when the source change is real implementation' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name facade -RustImplementationChanged $true) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + facade = New-SelectionDecision -Decision decline -Reason internal-only + } + classifications = @{ facade = 'patch' } + } + + $plan.status | Should -Be 'resolved' + @($plan.releases).Count | Should -Be 0 + } + + It 'allows a breaking classification when Rust implementation changed' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -RustImplementationChanged $true) ` + -Request @{ + mode = 'changed' + tokens = @('package@breaking') + selectionDecisions = @{ + package = New-SelectionDecision -Reason breaking + } + classifications = @{ package = 'breaking' } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].changeType | Should -Be 'breaking' + } + + It 'exempts a package whose breaking is forced by an exposed external dep' { + $facts = @( + New-ReleaseFact -Name macro_impl -Version '0.2.0' ` + -RustImplementationChanged $false ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'changed' + tokens = @('macro_impl@breaking') + selectionDecisions = @{ + macro_impl = New-SelectionDecision -Decision accept -Reason breaking + } + classifications = @{ macro_impl = 'breaking' } + } + + $plan.status | Should -Be 'resolved' + @($plan.ambiguities | Where-Object kind -eq 'ownClassificationUnsupported') | + Should -BeNullOrEmpty + $plan.releases[0].changeType | Should -Be 'breaking' + } + + It 'does not constrain the own classification of a first release' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name fresh -Version '0.1.0' ` + -EverReleased $false -RustImplementationChanged $false ` + -ModifiedFiles @('crates/fresh/src/lib.rs')) ` + -Request @{ + mode = 'changed' + tokens = @('fresh') + selectionDecisions = @{ + fresh = New-SelectionDecision -Reason first-release + } + classifications = @{} + } + + @($plan.ambiguities | Where-Object kind -eq 'ownClassificationUnsupported') | + Should -BeNullOrEmpty + } +} + +Describe 'resolve-plan.ps1 behavior-fix evidence' { + BeforeAll { + function Invoke-BehaviorFixPlan { + param( + [AllowNull()][AllowEmptyCollection()][object[]]$RegressionEvidence, + [switch]$OmitRegressionEvidence, + [string]$Reason = 'behavior-fix' + ) + + $decision = if ($OmitRegressionEvidence) { + New-SelectionDecision -Reason $Reason -RegressionEvidence @() + } elseif ($PSBoundParameters.ContainsKey('RegressionEvidence')) { + New-SelectionDecision -Reason $Reason -RegressionEvidence $RegressionEvidence + } else { + New-SelectionDecision -Reason $Reason + } + + return Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version '1.2.3') ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ package = $decision } + classifications = @{ package = 'patch' } + } + } + } + + It 'releases a behavior fix whose probe failed at the baseline and now passes' { + $plan = Invoke-BehaviorFixPlan + + $plan.status | Should -Be 'resolved' + @($plan.releases).Count | Should -Be 1 + $plan.releases[0].folder | Should -Be 'package' + $plan.selectionDecisions[0].regressionEvidence[0].outcome | Should -Be 'fail->pass' + } + + It 'blocks a behavior fix that records no probe at all' { + $plan = Invoke-BehaviorFixPlan -OmitRegressionEvidence + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'behaviorFixUndemonstrated' + $ambiguity | Should -Not -BeNullOrEmpty + $ambiguity.package | Should -Be 'package' + $ambiguity.requiredInput | + Should -Be 'selectionDecisions.package.regressionEvidence' + } + + BeforeDiscovery { + $unchangedCases = @( + @{ Name = 'preserved behavior'; Baseline = 'pass'; Current = 'pass' } + @{ Name = 'still broken behavior'; Baseline = 'fail'; Current = 'fail' } + @{ Name = 'newly broken behavior'; Baseline = 'pass'; Current = 'fail' } + ) + $kindCases = @( + @{ Kind = 'consumer-runtime' } + @{ Kind = 'consumer-compile' } + @{ Kind = 'packaged-artifact' } + ) + } + + It 'blocks a behavior fix whose probe shows ' -ForEach $unchangedCases { + $plan = Invoke-BehaviorFixPlan -RegressionEvidence @( + New-RegressionEvidence -Baseline $Baseline -Current $Current + ) + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'behaviorFixUndemonstrated' + $ambiguity | Should -Not -BeNullOrEmpty + $ambiguity.probes[0].outcome | Should -Be "$Baseline->$Current" + } + + It 'accepts a demonstrated fix measured by a probe' -ForEach $kindCases { + $plan = Invoke-BehaviorFixPlan -RegressionEvidence @( + New-RegressionEvidence -Kind $Kind + ) + + $plan.status | Should -Be 'resolved' + $plan.selectionDecisions[0].regressionEvidence[0].kind | Should -Be $Kind + } + + It 'keeps a demonstrated probe alongside probes that did not move' { + $plan = Invoke-BehaviorFixPlan -RegressionEvidence @( + New-RegressionEvidence -Probe 'cargo test --test unaffected' ` + -Baseline 'pass' -Current 'pass' + New-RegressionEvidence -Kind 'packaged-artifact' ` + -Probe 'cargo package --list' + ) + + $plan.status | Should -Be 'resolved' + @($plan.selectionDecisions[0].regressionEvidence).Count | Should -Be 2 + } + + It 'blocks a probe whose baseline was never measured' { + $plan = Invoke-BehaviorFixPlan -RegressionEvidence @( + New-RegressionEvidence -BaselineRevision '' + ) + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'behaviorEvidenceInconclusive' + $ambiguity | Should -Not -BeNullOrEmpty + $ambiguity.issues -join ' ' | Should -BeLike '*baseline pass/fail result*' + } + + It 'blocks a probe with no exit code' { + $plan = Invoke-BehaviorFixPlan -RegressionEvidence @( + New-RegressionEvidence -CurrentExitCode $null + ) + + $plan.status | Should -Be 'blocked' + ($plan.ambiguities | Where-Object kind -eq 'behaviorEvidenceInconclusive') | + Should -Not -BeNullOrEmpty + } + + It 'blocks a probe whose exit code contradicts its result' { + $plan = Invoke-BehaviorFixPlan -RegressionEvidence @( + New-RegressionEvidence -CurrentExitCode 101 + ) + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + $ambiguity = $plan.ambiguities | + Where-Object kind -eq 'behaviorEvidenceInconclusive' + $ambiguity.issues -join ' ' | Should -BeLike "*result of 'pass' with exit code 101*" + ($plan.ambiguities | Where-Object kind -eq 'behaviorFixUndemonstrated') | + Should -Not -BeNullOrEmpty + } + + It 'blocks a probe that measured one revision twice' { + $plan = Invoke-BehaviorFixPlan -RegressionEvidence @( + New-RegressionEvidence -BaselineRevision 'worktree' + ) + + $plan.status | Should -Be 'blocked' + ($plan.ambiguities | Where-Object kind -eq 'behaviorEvidenceInconclusive').issues -join ' ' | + Should -BeLike '*on both sides*' + } + + It 'rejects a probe that names no command' { + { Invoke-BehaviorFixPlan -RegressionEvidence @(New-RegressionEvidence -Probe ' ') } | + Should -Throw '*must name the probe it exercised*' + } + + It 'rejects a probe measured by an unrecognized kind' { + { Invoke-BehaviorFixPlan -RegressionEvidence @(New-RegressionEvidence -Kind 'vibes') } | + Should -Throw '*must use kind consumer-runtime, consumer-compile, packaged-artifact*' + } + + It 'rejects regression evidence written as prose' { + { Invoke-BehaviorFixPlan -RegressionEvidence @('The bug is fixed.') } | + Should -Throw '*must be an object with kind, probe, baseline, and current*' + } + + It 'leaves other accepted reasons unaffected' { + $plan = Invoke-BehaviorFixPlan -Reason 'nonbreaking-api' -OmitRegressionEvidence + + $plan.status | Should -Be 'resolved' + @($plan.releases).Count | Should -Be 1 + } + + It 'leaves declined packages unaffected' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name package -Version '1.2.3' + New-ReleaseFact -Name other -Version '1.0.0' + ) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision -Reason 'nonbreaking-api' + other = New-SelectionDecision -Decision 'decline' -Reason 'internal-only' + } + classifications = @{ package = 'patch' } + } + + $plan.status | Should -Be 'resolved' + @($plan.releases).Count | Should -Be 1 + } + + It 'blocks every undemonstrated behavior fix in the same plan' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name first -Version '1.2.3' + New-ReleaseFact -Name second -Version '1.2.3' + ) ` + -Request @{ + mode = 'changed' + tokens = @('first', 'second') + selectionDecisions = @{ + first = New-SelectionDecision -RegressionEvidence @( + New-RegressionEvidence -Baseline 'pass' -Current 'pass' + ) + second = New-SelectionDecision -RegressionEvidence @() + } + classifications = @{ first = 'patch'; second = 'patch' } + } + + $plan.status | Should -Be 'blocked' + @($plan.releases).Count | Should -Be 0 + @($plan.ambiguities | Where-Object kind -eq 'behaviorFixUndemonstrated').Count | + Should -Be 2 + } +} + +Describe 'resolve-plan.ps1 pins and validation' { + It 'honors a pin that already satisfies a breaking cascade floor' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name consumer -Deps core -ExposedDeps core + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking', 'consumer@5.0.0') + classifications = @{ core = 'patch'; consumer = 'patch' } + } + + ($plan.releases | Where-Object folder -eq consumer).to | Should -Be '5.0.0' + } + + It 'rejects a pin below the required cascade target' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name consumer -Deps core -ExposedDeps core + ) + + { + Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking', 'consumer@1.1.0') + classifications = @{ core = 'patch'; consumer = 'patch' } + } + } | Should -Throw '*below the required*' + } + + It 'keeps a conflicting pin under force and emits a warning' { + $facts = @( + New-ReleaseFact -Name core + New-ReleaseFact -Name consumer -Deps core -ExposedDeps core + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking', 'consumer@1.1.0') + classifications = @{ core = 'patch'; consumer = 'patch' } + force = $true + } + + $consumer = $plan.releases | Where-Object folder -eq consumer + $consumer.to | Should -Be '1.1.0' + $consumer.changeType | Should -Be 'breaking' + @($plan.warnings).Count | Should -Be 1 + } + + It 'rejects pins that are not strictly greater than the current version' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version '1.2.3') ` + -Request @{ + mode = 'targeted' + tokens = @('package@1.2.3') + classifications = @{ package = 'patch' } + } + } | Should -Throw '*must be strictly greater*' + } + + It 'rejects build-only pins because build metadata has equal precedence' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version '1.2.3+old') ` + -Request @{ + mode = 'targeted' + tokens = @('package@1.2.3+new') + classifications = @{ package = 'patch' } + } + } | Should -Throw '*must be strictly greater*' + } + + It 'accepts a greater pin and preserves build metadata' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version '1.2.3-alpha.1') ` + -Request @{ + mode = 'targeted' + tokens = @('package@1.2.4+build.7') + classifications = @{ package = 'patch' } + } + + $plan.releases[0].to | Should -Be '1.2.4+build.7' + } + + It 'matches hyphenated Cargo names from normalized tokens' { + $fact = New-ReleaseFact -Name package_name + $fact.name = 'package-name' + $plan = Invoke-ReleasePlan -Facts @($fact) -Request @{ + mode = 'targeted' + tokens = @('package-name@patch') + classifications = @{ package_name = 'patch' } + } + + $plan.releases[0].folder | Should -Be 'package_name' + } + + It 'fails rather than guessing a missing ordinary-library classification' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ mode = 'targeted'; tokens = @('package'); classifications = @{} } + } | Should -Throw '*Missing objective classification*' + } + + It 'rejects duplicate package tokens' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'targeted' + tokens = @('package@patch', 'package@breaking') + classifications = @{ package = 'patch' } + } + } | Should -Throw '*appears more than once*' + } + + It 'rejects unknown and unpublished packages' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'targeted' + tokens = @('missing') + classifications = @{} + } + } | Should -Throw '*matched 0 workspace packages*' + + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Published $false) ` + -Request @{ + mode = 'targeted' + tokens = @('package') + classifications = @{} + } + } | Should -Throw '*is not publishable*' + } + + It 'rejects malformed modes, change types, and empty requests' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'invalid' + tokens = @('package') + classifications = @{ package = 'patch' } + } + } | Should -Throw '*Unknown release mode*' + + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'targeted' + tokens = @('package@major') + classifications = @{ package = 'patch' } + } + } | Should -Throw '*Invalid SemVer version*' + + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'targeted' + tokens = @() + classifications = @{ package = 'patch' } + } + } | Should -Throw '*requires at least one accepted package token*' + } + + It 'rejects stale facts schemas and missing macro fact fields' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'targeted' + tokens = @('package') + classifications = @{ package = 'patch' } + } ` + -SchemaVersion 3 + } | Should -Throw '*unsupported schema*' + + $fact = New-ReleaseFact -Name package + $fact.Remove('macroPublicDeps') + { + Invoke-ReleasePlan ` + -Facts @($fact) ` + -Request @{ + mode = 'targeted' + tokens = @('package') + classifications = @{ package = 'patch' } + } + } | Should -Throw "*missing 'macroPublicDeps'*" + + $fact = New-ReleaseFact -Name package + $fact.Remove('modifiedFiles') + { + Invoke-ReleasePlan ` + -Facts @($fact) ` + -Request @{ + mode = 'targeted' + tokens = @('package') + classifications = @{ package = 'patch' } + } + } | Should -Throw "*missing 'modifiedFiles'*" + + $fact = New-ReleaseFact -Name package + $fact.Remove('manifestDependencyScopes') + { + Invoke-ReleasePlan ` + -Facts @($fact) ` + -Request @{ + mode = 'targeted' + tokens = @('package') + classifications = @{ package = 'patch' } + } + } | Should -Throw "*missing 'manifestDependencyScopes'*" + + $fact = New-ReleaseFact -Name package + $fact.Remove('manifestOtherChanged') + { + Invoke-ReleasePlan ` + -Facts @($fact) ` + -Request @{ + mode = 'targeted' + tokens = @('package') + classifications = @{ package = 'patch' } + } + } | Should -Throw "*missing 'manifestOtherChanged'*" + } + + It 'treats a JSON null modifiedFiles field as an empty file list' { + $fact = New-ReleaseFact -Name package + $fact.modifiedFiles = $null + $fact.modifiedFileCount = 0 + + $plan = Invoke-ReleasePlan ` + -Facts @($fact) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + package = New-SelectionDecision -Decision decline -Reason internal-only + } + classifications = @{} + } + + $plan.status | Should -Be 'resolved' + @($plan.releases).Count | Should -Be 0 + } + + It 'rejects malformed and contradictory macro contracts clearly' { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name macros -ProcMacroOnly $true + ) ` + -Request @{ + mode = 'targeted' + tokens = @('macros@patch') + classifications = @{} + macroContracts = @{ + macros = @{ verdict = 'compatible' } + } + } + } | Should -Throw '*must include reviewedPackages, channels, and evidence*' + + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name macros -ProcMacroOnly $true + ) ` + -Request @{ + mode = 'targeted' + tokens = @('macros@breaking') + classifications = @{} + macroContracts = @{ + macros = New-MacroContract + } + } + } | Should -Throw '*conflicts with its*contract verdict*' + } + + It 'preserves changed and all mode labels after package selection' { + foreach ($mode in @('changed', 'all')) { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = $mode + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision + } + classifications = @{ package = 'patch' } + } + $plan.mode | Should -Be $mode + } + } + + It 'requires complete selection decisions in changed mode' { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name accepted + New-ReleaseFact -Name omitted + ) ` + -Request @{ + mode = 'changed' + tokens = @('accepted') + selectionDecisions = @{ + accepted = New-SelectionDecision + } + classifications = @{ + accepted = 'patch' + omitted = 'patch' + } + } + } | Should -Throw '*missing candidate packages: omitted*' + } + + It 'requires tokens to exactly match accepted changed-mode decisions' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision ` + -Decision decline ` + -Reason test-only + } + classifications = @{ package = 'patch' } + } + } | Should -Throw '*conflicts with its decline selection decision*' + + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name accepted) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + accepted = New-SelectionDecision + } + classifications = @{ accepted = 'patch' } + } + } | Should -Throw "*Accepted selection decision 'accepted' is missing*" + } + + It 'rejects a dev-only manifest change as a runtime release seed' { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name package ` + -ModifiedFiles 'crates/package/Cargo.toml' ` + -ManifestDependencyScopes dev + ) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision ` + -Reason runtime-manifest-change + } + classifications = @{ package = 'patch' } + } + } | Should -Throw "*requires a changed normal/build dependency or package feature*" + } + + It 'rejects declining a runtime manifest change' { + foreach ($scope in @('normal', 'build', 'features')) { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name package ` + -ModifiedFiles 'crates/package/Cargo.toml' ` + -ManifestDependencyScopes $scope + ) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + package = New-SelectionDecision ` + -Decision decline ` + -Reason test-only + } + classifications = @{ package = 'patch' } + } + } | Should -Throw '*cannot decline a changed normal/build dependency or package feature*' + } + } + + It 'rejects relabeling a dev-only manifest change as another accepted reason' { + foreach ($reason in @( + 'breaking', + 'nonbreaking-api', + 'behavior-fix', + 'authored-doc-fix' + )) { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name package ` + -ModifiedFiles @( + 'crates/package/Cargo.toml', + 'crates/package/README.md' + ) ` + -ManifestDependencyScopes dev + ) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision -Reason $reason + } + classifications = @{ package = 'patch' } + } + } | Should -Throw '*cannot accept a dev-dependency-only manifest change*' + } + } + + It 'allows an evidenced non-dependency manifest change alongside a dev dependency edit' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name package ` + -ModifiedFiles 'crates/package/Cargo.toml' ` + -ManifestDependencyScopes dev ` + -ManifestOtherChanged $true + ) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision -Reason behavior-fix + } + classifications = @{ package = 'patch' } + } + + $plan.releases[0].folder | Should -Be 'package' + } + + It 'allows benchmark-only manifest and authored-file changes to decline' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name package ` + -ModifiedFiles @( + 'crates/package/Cargo.toml', + 'crates/package/benches/throughput.rs' + ) ` + -ManifestOtherChanged $true + ) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + package = New-SelectionDecision ` + -Decision decline ` + -Reason benchmark-only + } + classifications = @{ package = 'patch' } + } + + $plan.releases.Count | Should -Be 0 + } + + It 'requires the canonical reason for a pure dev dependency manifest edit' { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name package ` + -ModifiedFiles 'crates/package/Cargo.toml' ` + -ManifestDependencyScopes dev + ) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + package = New-SelectionDecision ` + -Decision decline ` + -Reason release-metadata-only + } + classifications = @{ package = 'patch' } + } + } | Should -Throw "*must classify a pure dev dependency manifest edit as 'dev-dependency-only'*" + } + + It 'accepts runtime dependency changes and dev-only declines' { + $runtimePlan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name runtime ` + -ModifiedFiles 'crates/runtime/Cargo.toml' ` + -ManifestDependencyScopes normal + ) ` + -Request @{ + mode = 'changed' + tokens = @('runtime') + selectionDecisions = @{ + runtime = New-SelectionDecision ` + -Reason runtime-manifest-change + } + classifications = @{ runtime = 'patch' } + } + $runtimePlan.releases[0].folder | Should -Be 'runtime' + + $featurePlan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name features ` + -ModifiedFiles 'crates/features/Cargo.toml' ` + -ManifestDependencyScopes features + ) ` + -Request @{ + mode = 'changed' + tokens = @('features') + selectionDecisions = @{ + features = New-SelectionDecision ` + -Reason runtime-manifest-change + } + classifications = @{ features = 'patch' } + } + $featurePlan.releases[0].folder | Should -Be 'features' + + $devPlan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name devonly ` + -ModifiedFiles 'crates/devonly/Cargo.toml' ` + -ManifestDependencyScopes dev + ) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + devonly = New-SelectionDecision ` + -Decision decline ` + -Reason dev-dependency-only + } + classifications = @{ devonly = 'patch' } + } + $devPlan.releases.Count | Should -Be 0 + } + + It 'rejects dev-dependency-only when other authored files changed' { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact ` + -Name package ` + -ModifiedFiles @( + 'crates/package/Cargo.toml', + 'crates/package/src/lib.rs' + ) ` + -ManifestDependencyScopes dev + ) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + package = New-SelectionDecision ` + -Decision decline ` + -Reason dev-dependency-only + } + classifications = @{ package = 'patch' } + } + } | Should -Throw '*cannot ignore changed source*' + } + + It 'emits normalized selection decisions' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name accepted + New-ReleaseFact -Name declined ` + -ModifiedFiles @('crates/declined/README.md') + ) ` + -Request @{ + mode = 'changed' + tokens = @('accepted') + selectionDecisions = @{ + accepted = New-SelectionDecision + declined = New-SelectionDecision ` + -Decision decline ` + -Reason generated-artifact-only + } + classifications = @{ + accepted = 'patch' + declined = 'patch' + } + } + + @($plan.selectionDecisions.package) | + Should -Be @('accepted', 'declined') + $plan.selectionDecisions[1].reason | + Should -Be 'generated-artifact-only' + } + + It 'resolves a complete all-declined request as an empty plan' { + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'changed' + tokens = @() + selectionDecisions = @{ + package = New-SelectionDecision ` + -Decision decline ` + -Reason test-only + } + classifications = @{ package = 'patch' } + } + + $plan.status | Should -Be 'resolved' + $plan.releases.Count | Should -Be 0 + $plan.selectionDecisions[0].decision | Should -Be 'decline' + } + + It 'rejects extra or aliased selection decision keys' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision + package_alias = New-SelectionDecision + } + classifications = @{ package = 'patch' } + } + } | Should -Throw '*unknown or non-candidate packages: package_alias*' + + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package-name) ` + -Request @{ + mode = 'changed' + tokens = @('package-name') + selectionDecisions = @{ + package_name = New-SelectionDecision + } + classifications = @{ package_name = 'patch' } + } + } | Should -Throw '*Use canonical folder identifiers*' + } + + It 'supports an explicit unchanged release only in all mode' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name package -Modified $false + ) ` + -Request @{ + mode = 'all' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision ` + -Reason explicit-release + } + classifications = @{ package = 'patch' } + } + + @($plan.releases.folder) | Should -Be @('package') + + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision ` + -Reason explicit-release + } + classifications = @{ package = 'patch' } + } + } | Should -Throw "*only valid for an unchanged package in all mode*" + } + + It 'rejects a first release justified only by tests' { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name package -EverReleased $false ` + -ModifiedFiles @('crates/package/tests/behavior.rs') + ) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision -Reason first-release + } + classifications = @{} + } + } | Should -Throw "*requires a changed packaged file outside tests*" + } + + It 'accepts a first release with changed packaged source' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name package -EverReleased $false ` + -ModifiedFiles @('crates/package/src/lib.rs') + ) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision -Reason first-release + } + classifications = @{} + } + + $plan.releases[0].to | Should -Be '1.0.0' + } + + It 'requires every accepted never-released package to use first-release' { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name package -EverReleased $false ` + -ModifiedFiles @('crates/package/tests/behavior.rs') + ) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision -Reason behavior-fix + } + classifications = @{} + } + } | Should -Throw "*must use selection reason 'first-release'*" + } + + It 'rejects non-packaged or generated first-release evidence' { + foreach ($path in @( + 'crates/package/logo.png', + 'crates/package/Cargo.toml', + 'crates/package/README.md' + )) { + { + Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name package -EverReleased $false ` + -ModifiedFiles @($path) + ) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{ + package = New-SelectionDecision -Reason first-release + } + classifications = @{} + } + } | Should -Throw "*requires a changed packaged file outside tests*" + } + } + + It 'matches first-release paths ordinally instead of as wildcard patterns' { + $plan = Invoke-ReleasePlan ` + -Facts @( + New-ReleaseFact -Name '[package]' -EverReleased $false ` + -ModifiedFiles @('crates/[package]/src/lib.rs') + ) ` + -Request @{ + mode = 'changed' + tokens = @('[package]') + selectionDecisions = @{ + '[package]' = New-SelectionDecision -Reason first-release + } + classifications = @{} + } + + $plan.releases[0].folder | Should -Be '[package]' + } + + It 'rejects request-owned manual review flags' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package) ` + -Request @{ + mode = 'targeted' + tokens = @('package') + classifications = @{ + package = @{ + changeType = 'patch' + manualReview = $true + } + } + } + } | Should -Throw "*manualReview for 'package' is resolver-owned*" + } + + It 'rejects changed-mode tokens for non-candidates' { + { + Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Modified $false) ` + -Request @{ + mode = 'changed' + tokens = @('package') + selectionDecisions = @{} + classifications = @{ package = 'patch' } + } + } | Should -Throw '*is not a candidate in changed mode*' + } + + Describe 'resolve-plan.ps1 generated exposure matrix' { + BeforeDiscovery { + $versions = @('1.2.3', '0.4.2', '0.0.5') + $changes = @('patch', 'nonbreaking', 'breaking') + $exposureCases = foreach ($dependencyVersion in $versions) { + foreach ($change in $changes) { + foreach ($consumerVersion in $versions) { + foreach ($exposed in @($false, $true)) { + @{ + Name = "$dependencyVersion $change -> $consumerVersion exposed=$exposed" + DependencyVersion = $dependencyVersion + Change = $change + ConsumerVersion = $consumerVersion + Exposed = $exposed + } + } + } + } + } + } + + It '' -ForEach $exposureCases { + $facts = @( + New-ReleaseFact -Name dependency -Version $DependencyVersion + New-ReleaseFact ` + -Name consumer ` + -Version $ConsumerVersion ` + -Deps dependency ` + -ExposedDeps $(if ($Exposed) { @('dependency') } else { @() }) + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @("dependency@$Change") + classifications = @{ dependency = 'patch'; consumer = 'patch' } + } + + $internalChange = if ($Change -eq 'nonbreaking') { + 'non-breaking' + } else { + $Change + } + $dependencyBreaks = Test-IsBreakingChange ` + -oldVersion $DependencyVersion ` + -ChangeType $internalChange + $expectedConsumerChange = if ($Exposed -and $dependencyBreaks) { + 'breaking' + } else { + 'patch' + } + $consumer = $plan.releases | Where-Object folder -eq consumer + $consumer.changeType | Should -Be $expectedConsumerChange + $consumer.to | Should -Be ( + Get-NextVersion ` + -currentVersion $ConsumerVersion ` + -ChangeType $expectedConsumerChange + ) + $consumer.cascadeReasons[0].breaking | + Should -Be ($Exposed -and $dependencyBreaks) + } + } + + Describe 'resolve-plan.ps1 generated lower-bound matrix' { + BeforeDiscovery { + $versions = @('1.2.3', '0.4.2', '0.0.5') + $objectives = @('patch', 'nonbreaking', 'breaking') + $requests = @('', 'patch', 'nonbreaking', 'breaking') + $lowerBoundCases = foreach ($version in $versions) { + foreach ($objective in $objectives) { + foreach ($request in $requests) { + @{ + Name = "$version objective=$objective request=$request" + Version = $version + Objective = $objective + Request = $request + } + } + } + } + } + + It '' -ForEach $lowerBoundCases { + $rank = @{ patch = 1; nonbreaking = 2; breaking = 3 } + $expectedChange = if ( + [string]::IsNullOrEmpty($Request) -or + $rank[$Objective] -ge $rank[$Request] + ) { + $Objective + } else { + $Request + } + $token = if ([string]::IsNullOrEmpty($Request)) { + 'package' + } else { + "package@$Request" + } + $plan = Invoke-ReleasePlan ` + -Facts @(New-ReleaseFact -Name package -Version $Version) ` + -Request @{ + mode = 'targeted' + tokens = @($token) + classifications = @{ package = $Objective } + } + + $internalChange = if ($expectedChange -eq 'nonbreaking') { + 'non-breaking' + } else { + $expectedChange + } + $plan.releases[0].changeType | Should -Be $expectedChange + $plan.releases[0].to | Should -Be ( + Get-NextVersion ` + -currentVersion $Version ` + -ChangeType $internalChange + ) + } + } + + Describe 'resolve-plan.ps1 generated fixed-point matrix' { + BeforeDiscovery { + $versions = @('1.2.3', '0.4.2', '0.0.5') + $changes = @('patch', 'nonbreaking', 'breaking') + $fixedPointCases = foreach ($dependencyVersion in $versions) { + foreach ($change in $changes) { + foreach ($firstExposed in @($false, $true)) { + foreach ($secondExposed in @($false, $true)) { + @{ + Name = "$dependencyVersion $change edges=$firstExposed/$secondExposed" + DependencyVersion = $dependencyVersion + Change = $change + FirstExposed = $firstExposed + SecondExposed = $secondExposed + } + } + } + } + } + } + + It '' -ForEach $fixedPointCases { + $facts = @( + New-ReleaseFact -Name bottom -Version $DependencyVersion + New-ReleaseFact ` + -Name middle ` + -Deps bottom ` + -ExposedDeps $(if ($FirstExposed) { @('bottom') } else { @() }) + New-ReleaseFact ` + -Name top ` + -Deps middle ` + -ExposedDeps $(if ($SecondExposed) { @('middle') } else { @() }) + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @("bottom@$Change") + classifications = @{ bottom = 'patch'; middle = 'patch'; top = 'patch' } + } + + $internalChange = if ($Change -eq 'nonbreaking') { + 'non-breaking' + } else { + $Change + } + $bottomBreaks = Test-IsBreakingChange ` + -oldVersion $DependencyVersion ` + -ChangeType $internalChange + $middleBreaking = $bottomBreaks -and $FirstExposed + $topBreaking = $middleBreaking -and $SecondExposed + ($plan.releases | Where-Object folder -eq middle).changeType | + Should -Be $(if ($middleBreaking) { 'breaking' } else { 'patch' }) + ($plan.releases | Where-Object folder -eq top).changeType | + Should -Be $(if ($topBreaking) { 'breaking' } else { 'patch' }) + } + } + + Describe 'resolve-plan.ps1 generated diamond matrix' { + BeforeDiscovery { + $versions = @('1.2.3', '0.4.2', '0.0.5') + $changes = @('patch', 'nonbreaking', 'breaking') + $diamondCases = foreach ($dependencyVersion in $versions) { + foreach ($change in $changes) { + foreach ($mask in 0..15) { + @{ + Name = "$dependencyVersion $change diamond-mask=$mask" + DependencyVersion = $dependencyVersion + Change = $change + RootToLeft = [bool]($mask -band 1) + RootToRight = [bool]($mask -band 2) + LeftToTop = [bool]($mask -band 4) + RightToTop = [bool]($mask -band 8) + } + } + } + } + } + + It '' -ForEach $diamondCases { + $facts = @( + New-ReleaseFact -Name root -Version $DependencyVersion + New-ReleaseFact ` + -Name left ` + -Deps root ` + -ExposedDeps $(if ($RootToLeft) { @('root') } else { @() }) + New-ReleaseFact ` + -Name right ` + -Deps root ` + -ExposedDeps $(if ($RootToRight) { @('root') } else { @() }) + New-ReleaseFact ` + -Name top ` + -Deps @('left', 'right') ` + -ExposedDeps @( + if ($LeftToTop) { 'left' } + if ($RightToTop) { 'right' } + ) + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @("root@$Change") + classifications = @{ + root = 'patch' + left = 'patch' + right = 'patch' + top = 'patch' + } + } + + $internalChange = if ($Change -eq 'nonbreaking') { + 'non-breaking' + } else { + $Change + } + $rootBreaks = Test-IsBreakingChange ` + -oldVersion $DependencyVersion ` + -ChangeType $internalChange + $leftBreaks = $rootBreaks -and $RootToLeft + $rightBreaks = $rootBreaks -and $RootToRight + $topBreaks = + ($leftBreaks -and $LeftToTop) -or + ($rightBreaks -and $RightToTop) + + ($plan.releases | Where-Object folder -eq left).changeType | + Should -Be $(if ($leftBreaks) { 'breaking' } else { 'patch' }) + ($plan.releases | Where-Object folder -eq right).changeType | + Should -Be $(if ($rightBreaks) { 'breaking' } else { 'patch' }) + $top = $plan.releases | Where-Object folder -eq top + $top.changeType | + Should -Be $(if ($topBreaks) { 'breaking' } else { 'patch' }) + @($top.cascadeReasons).Count | Should -Be 2 + @($top.cascadeReasons.target) | Should -Be @('left', 'right') + } + } + + It 'rejects dependency cycles in the supplied fact graph' { + $facts = @( + New-ReleaseFact -Name left -Deps right + New-ReleaseFact -Name right -Deps left + ) + + { + Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('left@patch') + classifications = @{ left = 'patch'; right = 'patch' } + } + } | Should -Throw '*dependency cycle*' + } +} + +Describe 'resolve-plan.ps1 external dependency exposure' { + It 'blocks a breaking exposed dependency bump declared as a patch' { + # The run-8 shape for manifests: nothing in the crate's own rustdoc + # moved, so cargo-semver-checks reports patch, while every consumer that + # names syn::Error now sees a different type. + $facts = @( + New-ReleaseFact -Name macro_impl ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macro_impl') + classifications = @{ macro_impl = 'patch' } + } + + $plan.status | Should -Be 'blocked' + $plan.releases.Count | Should -Be 0 + @($plan.ambiguities | ForEach-Object { $_.kind }) | + Should -Contain 'externalExposureUnderclassified' + } + + It 'blocks a nonbreaking classification just as it blocks a patch' { + $facts = @( + New-ReleaseFact -Name macro_impl ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macro_impl') + classifications = @{ macro_impl = 'nonbreaking' } + } + + $plan.status | Should -Be 'blocked' + $plan.releases.Count | Should -Be 0 + } + + It 'reports the dependency, both requirements and the derived floor' { + $facts = @( + New-ReleaseFact -Name macro_impl ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macro_impl') + classifications = @{ macro_impl = 'patch' } + } + + $ambiguity = @( + $plan.ambiguities | + Where-Object { $_.kind -eq 'externalExposureUnderclassified' } + )[0] + $ambiguity.package | Should -Be 'macro_impl' + $ambiguity.classified | Should -Be 'patch' + $ambiguity.derivedFloor | Should -Be 'breaking' + $ambiguity.dependencies[0].name | Should -Be 'syn' + $ambiguity.dependencies[0].baselineReq | Should -Be '^2.0.111' + $ambiguity.dependencies[0].currentReq | Should -Be '^3.0.2' + $ambiguity.requiredInput | Should -Be 'classifications.macro_impl' + } + + It 'resolves once the classification meets the derived floor' { + $facts = @( + New-ReleaseFact -Name macro_impl -Version '0.2.0' ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macro_impl') + classifications = @{ macro_impl = 'breaking' } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].folder | Should -Be 'macro_impl' + $plan.releases[0].to | Should -Be '0.3.0' + } + + It 'does not break on a private external dependency bump' { + $facts = @( + New-ReleaseFact -Name private_user -Version '1.2.3' ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('serde') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('private_user') + classifications = @{ private_user = 'patch' } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].to | Should -Be '1.2.4' + } + + It 'does not break on a non-breaking requirement change to an exposed dependency' { + $facts = @( + New-ReleaseFact -Name exposer -Version '1.2.3' ` + -ExternalDepChanges @( + New-ExternalDepChange -Name syn ` + -BaselineReq '^2.0.111' -CurrentReq '^2.9.0' -Breaking $false + ) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('exposer') + classifications = @{ exposer = 'patch' } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].to | Should -Be '1.2.4' + } + + It 'does not break a proc macro, whose exposure set is always empty' { + $facts = @( + New-ReleaseFact -Name macros -Version '0.4.0' -ProcMacroOnly $true ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @() + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macros') + classifications = @{ macros = 'patch' } + macroContracts = @{ macros = New-MacroContract -ReviewedPackages @('macros') } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].to | Should -Be '0.4.1' + } + + It 'never floors a crate that has never been released' { + $facts = @( + New-ReleaseFact -Name newcomer -Version '0.1.0' -EverReleased $false ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('newcomer') + classifications = @{} + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].to | Should -Be '0.1.0' + } + + It 'never floors on a dropped dependency, which cannot be exposed any more' { + $facts = @( + New-ReleaseFact -Name dropper -Version '1.2.3' ` + -ExternalDepChanges @( + New-ExternalDepChange -Name anyhow ` + -BaselineReq '^1.0.100' -CurrentReq $null -Breaking $true + ) ` + -ExternalExposedDeps @('serde') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('dropper') + classifications = @{ dropper = 'patch' } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].to | Should -Be '1.2.4' + } + + It 'cascades the derived break to workspace dependents that expose it' { + $facts = @( + New-ReleaseFact -Name macro_impl -Version '0.2.0' ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + New-ReleaseFact -Name facade -Version '0.2.0' -Deps macro_impl ` + -ExposedDeps macro_impl -Modified $false + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('macro_impl') + classifications = @{ macro_impl = 'breaking'; facade = 'patch' } + } + + $plan.status | Should -Be 'resolved' + $byPackage = @{} + foreach ($release in $plan.releases) { $byPackage[$release.folder] = $release } + $byPackage['macro_impl'].to | Should -Be '0.3.0' + $byPackage['facade'].to | Should -Be '0.3.0' + } + + It 'blocks a dependent that carries its own exposed break at a patch floor' { + $facts = @( + New-ReleaseFact -Name core -Version '0.2.0' + New-ReleaseFact -Name dependent -Version '0.2.0' -Deps core ` + -ExposedDeps core -Modified $false ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'targeted' + tokens = @('core@breaking') + classifications = @{ core = 'breaking'; dependent = 'patch' } + } + + $plan.status | Should -Be 'blocked' + $plan.releases.Count | Should -Be 0 + @($plan.ambiguities | ForEach-Object { $_.kind }) | + Should -Contain 'externalExposureUnderclassified' + } + + Context 'selection reason coupling' { + It 'blocks a declined package whose exposed dependency break is real' { + $facts = @( + New-ReleaseFact -Name macro_impl ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'changed' + tokens = @() + classifications = @{ macro_impl = 'patch' } + selectionDecisions = @{ + macro_impl = New-SelectionDecision ` + -Decision 'decline' -Reason 'internal-only' + } + } + + $plan.status | Should -Be 'blocked' + $plan.releases.Count | Should -Be 0 + @($plan.ambiguities | ForEach-Object { $_.kind }) | + Should -Contain 'externalExposureUnderselected' + } + + It 'blocks an accepted package whose reason is softer than the derived floor' { + $facts = @( + New-ReleaseFact -Name macro_impl ` + -ManifestDependencyScopes @('normal') ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'changed' + tokens = @('macro_impl') + classifications = @{ macro_impl = 'breaking' } + selectionDecisions = @{ + macro_impl = New-SelectionDecision ` + -Decision 'accept' -Reason 'runtime-manifest-change' + } + } + + $plan.status | Should -Be 'blocked' + $plan.releases.Count | Should -Be 0 + $ambiguity = @( + $plan.ambiguities | + Where-Object { $_.kind -eq 'externalExposureUnderselected' } + )[0] + $ambiguity.reason | Should -Be 'runtime-manifest-change' + $ambiguity.derivedFloor | Should -Be 'breaking' + $ambiguity.requiredInput | Should -Be 'selectionDecisions.macro_impl.reason' + } + + It 'resolves when both the reason and the classification meet the floor' { + $facts = @( + New-ReleaseFact -Name macro_impl -Version '0.2.0' ` + -ExternalDepChanges @(New-ExternalDepChange -Name syn) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'changed' + tokens = @('macro_impl') + classifications = @{ macro_impl = 'breaking' } + selectionDecisions = @{ + macro_impl = New-SelectionDecision -Decision 'accept' -Reason 'breaking' + } + } + + $plan.status | Should -Be 'resolved' + $plan.releases[0].to | Should -Be '0.3.0' + } + + It 'leaves an unaffected package free to decline' { + $facts = @( + New-ReleaseFact -Name plain ` + -ExternalDepChanges @( + New-ExternalDepChange -Name syn -Breaking $false ` + -BaselineReq '^2.0.111' -CurrentReq '^2.9.0' + ) ` + -ExternalExposedDeps @('syn') + ) + $plan = Invoke-ReleasePlan -Facts $facts -Request @{ + mode = 'changed' + tokens = @() + classifications = @{ plain = 'patch' } + selectionDecisions = @{ + plain = New-SelectionDecision -Decision 'decline' -Reason 'internal-only' + } + } + + $plan.status | Should -Be 'resolved' + $plan.releases.Count | Should -Be 0 + } + } + + It 'rejects facts that predate the external dependency lane' { + $fact = New-ReleaseFact -Name package + $fact.Remove('externalDepChanges') + + { + Invoke-ReleasePlan -Facts @($fact) -Request @{ + mode = 'targeted' + tokens = @('package@patch') + classifications = @{ package = 'patch' } + } + } | Should -Throw "*missing 'externalDepChanges'*" + } +} diff --git a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 index a19d79ef6..6abd944a9 100644 --- a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 @@ -17,7 +17,7 @@ BeforeAll { . (Join-Path $PSScriptRoot '..\..\_common\TestHelpers.ps1') - . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\release-flow.ps1') + . (Join-Path (Get-OxiRepoRoot) 'scripts\lib\changelog.ps1') } Describe 'Write-Changelog cascade emission' {