breakage 0.8.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +163 -47
  2. package/dist/{breakage-B7dJsgcu.js → breakage-DIY49fzP.js} +331 -217
  3. package/dist/breakage-DIY49fzP.js.map +1 -0
  4. package/dist/breakage.d.ts +111 -55
  5. package/dist/breakage.d.ts.map +1 -1
  6. package/dist/breakage.js +3 -1
  7. package/dist/breakage.x.js +78 -80
  8. package/dist/breakage.x.js.map +1 -1
  9. package/dist/changesets.d.ts +8 -0
  10. package/dist/changesets.d.ts.map +1 -0
  11. package/dist/changesets.js +27 -0
  12. package/dist/changesets.js.map +1 -0
  13. package/dist/release-BsdIDT4g.js +69 -0
  14. package/dist/release-BsdIDT4g.js.map +1 -0
  15. package/dist/release-D47BqRuB.d.ts +40 -0
  16. package/dist/release-D47BqRuB.d.ts.map +1 -0
  17. package/npm.md +55 -33
  18. package/package.json +45 -6
  19. package/src/breakage.ts +125 -73
  20. package/src/breakage.x.ts +148 -127
  21. package/src/changesets.ts +51 -0
  22. package/src/config.ts +128 -0
  23. package/src/npm/check.ts +51 -50
  24. package/src/npm/cli.ts +26 -22
  25. package/src/npm/config.ts +38 -6
  26. package/src/npm/environment.ts +10 -10
  27. package/src/npm/release.ts +9 -25
  28. package/src/npm/types.ts +5 -45
  29. package/src/release.ts +153 -0
  30. package/src/{npm/command.ts → task.ts} +2 -0
  31. package/dist/breakage-B7dJsgcu.js.map +0 -1
  32. package/dist/breakage.$configPath.schema.json +0 -33
  33. package/dist/breakage.help.schema.json +0 -6
  34. package/dist/breakage.main.schema.json +0 -33
  35. package/dist/breakage.npm.check.schema.json +0 -16
  36. package/dist/breakage.npm.help.schema.json +0 -6
  37. package/dist/breakage.npm.schema.json +0 -6
  38. package/dist/breakage.npm.test.$peer.$range.schema.json +0 -16
  39. package/dist/breakage.npm.test.schema.json +0 -16
  40. package/dist/breakage.prelude.$configPath.schema.json +0 -31
  41. package/dist/breakage.prelude.schema.json +0 -31
  42. package/dist/breakage.schema.schema.json +0 -10
package/npm.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Use `breakage npm` to test a packed package against the peer versions you declare. This is an optional npm-specific feature. The ordinary public check and its four adoption conditions remain the same; keep that check required alongside peer compatibility.
4
4
 
5
- Consider an integration with `"atom.io": "^1 || ^2"`. Its current workspace tests may pass against an edited core, even if only the integration will ship. Representative environments let it prove compatibility with actual released versions. An explicitly registered candidate environment can additionally prove conditional compatibility with a core release your release plan certifies.
5
+ Consider an integration with `"atom.io": "^1 || ^2"`. Its current workspace tests may pass against an edited core, even if only the integration will ship. Representative environments let it prove compatibility with actual released versions. An explicitly registered candidate environment can additionally prove conditional compatibility with a core release your release plan identifies.
6
6
 
7
7
  The npm commands require Node 22.18 or newer (including native erasable TypeScript for `.ts` configuration), Git, and your installation/test tools. They run on the filesystem, inside complete representative directories. They do not publish packages.
8
8
 
@@ -30,7 +30,7 @@ In the integration package, create complete environments for the peer formulatio
30
30
  }
31
31
  ```
32
32
 
33
- Pin every dependency in an environment manifest to an exact version: `"host": "1.0.0"` and `"host": "2.1.4"` are valid; `"host": "^1"` and `"host": "^1 || ^2"` are setup errors. This applies to all entries in `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies`, including test runners and helpers. Exact prerelease and build versions are allowed. Ranges, wildcards, tags, aliases, workspace protocols, and file or URL specifiers are rejected before installation. The integration's own `peerDependencies` still express its supported eras with ranges. Enroll coordinated local releases through `breakage.candidates`; their certified intended versions supply the exact identities, and any accompanying dependency pins must match those versions.
33
+ Pin every dependency in an environment manifest to an exact version: `"host": "1.0.0"` and `"host": "2.1.4"` are valid; `"host": "^1"` and `"host": "^1 || ^2"` are setup errors. This applies to all entries in `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies`, including test runners and helpers. Exact prerelease and build versions are allowed. Ranges, wildcards, tags, aliases, workspace protocols, and file or URL specifiers are rejected before installation. The integration's own `peerDependencies` still express its supported eras with ranges. Enroll coordinated local releases through `breakage.candidates`; their resolved intended versions supply the exact identities, and any accompanying dependency pins must match those versions.
34
34
 
35
35
  Choose real published versions appropriate for your package. Install once and commit this directory's manifest and lock file. Add any test runner and helpers it needs to its own development dependencies with exact pins. Give each additional formulation its own directory and lock file. Every required peer of the integration must be present; several coupled peers share one environment. Include all intended representatives in the glob, including multiple representatives of the same era.
36
36
 
@@ -89,7 +89,7 @@ export default {
89
89
  }
90
90
  ```
91
91
 
92
- Every file glob must select at least one regular file. Remove the fixture glob if you have no fixtures. `packageDirectory` defaults to the config file's directory; all npm paths resolve from that package directory. Glob discovery uses paths as labels and installed versions as evidence. Register local peer directories in `localPeers`; each declared local peer must have a suite, and its certified upcoming releases must be supported and exercised by a coordinated representative. External peer suites are an explicit opt-in. No matrix is inferred from registry metadata.
92
+ Every file glob must select at least one regular file. Remove the fixture glob if you have no fixtures. `packageDirectory` defaults to the config file's directory; all npm paths resolve from that package directory. Glob discovery uses paths as labels and installed versions as evidence. Register local peer directories in `localPeers`; each declared local peer must have a suite, and its planned upcoming releases must be supported and exercised by a coordinated representative. External peer suites are an explicit opt-in. No matrix is inferred from registry metadata.
93
93
 
94
94
  The previous manifest comes from the newest matching release tag. An existing core `baselineFile` may pin a fetched release. For a package's first publication, explicitly use `baseline: { firstRelease: true }` and remove that exception after publishing; missing baseline evidence never silently passes.
95
95
 
@@ -103,9 +103,9 @@ breakage npm test --representative peer-environments/core-1
103
103
  breakage npm check --config ./breakage.config.ts --json
104
104
  ```
105
105
 
106
- All matching representatives run. Every explicit `||` era needs coverage. Preview versions cover only an exact preview alternative, not stable ranges or other previews. Build metadata does not affect range matching, including exact preview selection; installed versions must still match their complete environment pins. Released representatives outside declared support warn; excluding a planned local release fails the release check. Focused requests can intentionally explore versions outside declared support. An audit reports declarations and release-policy findings without claiming assertions passed. Exit statuses are 0 for successful requested work, 1 for compatibility or uncertified support failures, and 2 for setup or coverage errors.
106
+ All matching representatives run. Every explicit `||` era needs coverage. Preview versions cover only an exact preview alternative, not stable ranges or other previews. Build metadata does not affect range matching, including exact preview selection; installed versions must still match their complete environment pins. Released representatives outside declared support warn; excluding a planned local release fails the release check. Focused requests can intentionally explore versions outside declared support. An audit reports declarations and release-policy findings without claiming assertions passed. Exit statuses are 0 for successful requested work, 1 for compatibility or unapproved support failures, and 2 for setup or coverage errors.
107
107
 
108
- Focused peer/range requests validate manifest pins and skip versions outside the requested range before acquiring stages or preparing dependencies. Coordinated candidates are selected by their certified intended versions, and exact-preview selection follows the same rules as coverage. Excluded environments need no lockfile or available stage. Disjoint requests for the same peer can run independently in one checkout without parsing lock files. Requests that select the same environment still contend for its stage, including requests for different peers in a coupled environment. Selected installations retain all frozen-input, identity, and compatibility checks.
108
+ Focused peer/range requests validate manifest pins and skip versions outside the requested range before acquiring stages or preparing dependencies. Coordinated candidates are selected by their resolved intended versions, and exact-preview selection follows the same rules as coverage. Excluded environments need no lockfile or available stage. Disjoint requests for the same peer can run independently in one checkout without parsing lock files. Requests that select the same environment still contend for its stage, including requests for different peers in a coupled environment. Selected installations retain all frozen-input, identity, and compatibility checks.
109
109
 
110
110
  ## Coordinated releases
111
111
 
@@ -122,54 +122,76 @@ Create a separate representative that explicitly names candidate peers. Leave an
122
122
  }
123
123
  ```
124
124
 
125
- Add a typed `releaseIntent` callback to the npm configuration. This example reads a repository-owned JSON release plan; adapt it to Changesets or your release process. The function receives `packageName`, `packageDirectory`, and `currentVersion`, and may return synchronously or asynchronously:
125
+ Set `release.resolveDelta` to a synchronous/asynchronous resolver receiving `packageName`, `packageDirectory`, and `currentVersion`. For Changesets, use the optional helper:
126
126
 
127
127
  ```ts
128
- import { readFile } from "node:fs/promises"
129
- import type { NpmConfig } from "breakage"
128
+ import { changesetsReleaseDelta } from "breakage/changesets"
129
+ import { semverPolicy } from "breakage"
130
130
 
131
- const releaseIntent: NonNullable<NpmConfig["releaseIntent"]> = async ({
132
- packageName,
133
- }) => {
134
- const plan = JSON.parse(
135
- await readFile(new URL("./release-plan.json", import.meta.url), "utf8"),
136
- )
137
- const version = plan[packageName]
138
- return version ? { status: "release", version } : { status: "none" }
131
+ const release = {
132
+ resolveDelta: changesetsReleaseDelta(),
133
+ policy: semverPolicy({ beforeV1: "minor" }),
139
134
  }
140
135
  ```
141
136
 
142
- Missing intent blocks that candidate environment. Inspection errors and invalid evidence are setup errors, not absent intent. Intended versions must be exact and at least the current workspace version. The candidate archive may have its workspace version or the intended version. Breakage reports both versions. If they differ, it substitutes the intended version in the installed peer's disposable manifest using a fresh file and atomic replacement, preserving shared cache files, other environments, the archive, and the workspace. Archives already carrying their intended version need no substitution.
137
+ Assign this object to `npm.release`, or share it at the top level with `defineConfig`. A custom resolver returns `{ previousVersion, version, delta }` or `undefined`; errors must throw. Missing intent blocks that candidate environment. Inspection errors and invalid evidence are setup errors, not absent intent. Both transition versions must be exact, the target must be at least the current workspace version, and the delta must match the versions. Candidate identity uses the transition's `version`; no breaking-change policy is applied to candidate enrollment. The candidate archive may have its workspace version or the intended version. Breakage reports both versions. If they differ, it substitutes the intended version in the installed peer's disposable manifest using a fresh file and atomic replacement, preserving shared cache files, other environments, the archive, and the workspace. Archives already carrying their intended version need no substitution.
143
138
 
144
- That substitution simulates release metadata; it does not rebuild the package or change version constants embedded in executable code. Your build owns those constants. To exercise the final version metadata directly, supply a peer archive already built with the certified intended version.
139
+ That substitution simulates release metadata; it does not rebuild the package or change version constants embedded in executable code. Your build owns those constants. To exercise the final version metadata directly, supply a peer archive already built with the resolved intended version.
145
140
 
146
- Both `npm check` and its coverage-only audit inspect each currently declared `localPeers` package's release intent, even if you omitted its candidate environment. A certified intended version must be included in the integration's peer range and covered by a coordinated representative. Missing either fails with exit 2; an installed peer at the same version does not substitute for testing the local candidate. Optional local peers have the same obligation when declared. Hook errors and invalid evidence fail setup. Hooks are evaluated once per package per invocation, and must return that package's own release intent.
141
+ Both `npm check` and its coverage-only audit inspect each currently declared `localPeers` package's release intent, even if you omitted its candidate environment. A resolved intended version must be included in the integration's peer range and covered by a coordinated representative. Missing either fails with exit 2; an installed peer at the same version does not substitute for testing the local candidate. Optional local peers have the same obligation when declared. Resolver errors and invalid evidence fail setup. Resolvers are evaluated once per package and baseline context per invocation, and must return that package's own release intent.
147
142
 
148
- For a core still labeled `0.53.3` with a planned `0.54.0` release, an integration declaring only `^0.53.3` fails. Adding `|| ^0.54.0` is recommended when retaining the older era. Admission uses version membership: `0.54.0`, `*`, and other ranges containing that version qualify too. Exact-preview enrollment, coverage of other declared eras, and certification for dropping old support still apply. Your release hook supplies the intended version before a versioning PR is merged; Breakage does not infer it from an open PR.
143
+ For a core still labeled `0.53.3` with a planned `0.54.0` release, an integration declaring only `^0.53.3` fails. Adding `|| ^0.54.0` is recommended when retaining the older era. Admission uses version membership: `0.54.0`, `*`, and other ranges containing that version qualify too. Exact-preview enrollment, coverage of other declared eras, and breaking release evidence for dropping old support still apply. Your delta resolver supplies the intended transition before a versioning PR is merged; Breakage does not infer it from an open PR.
149
144
 
150
145
  Candidate results are conditional on publication. They can cover a new era, but cannot excuse an incompatible released version that the integration still advertises. Unreleased local edits with no release intent create no new coordination obligation. Registering a currently declared local peer means the integration must accommodate its planned releases. Registrations retained only for historical resolution do not impose this obligation after the peer declaration is removed. The repository owns publication order.
151
146
 
152
- Every coordinated candidate must also accept the representative's peer versions in its own normalized `peerDependencies`. For example, a candidate declaring `minimatch: ^8` cannot be approved alongside Minimatch 9, even if the integration declares `^9` and its assertions pass. Both full checks and coverage audits reject that conflict before candidate installation with exit 2, identifying the candidate, required range, and observed version. The representative must supply required peers needed only by a coordinated candidate too; these additional identities do not require separate integration suites. Absent optional peers are allowed, but present optional peers must satisfy their ranges. Another coordinated peer contributes its certified intended version; a peer on the integration itself uses the integration's current archive version.
147
+ Every coordinated candidate must also accept the representative's peer versions in its own normalized `peerDependencies`. For example, a candidate declaring `minimatch: ^8` cannot be approved alongside Minimatch 9, even if the integration declares `^9` and its assertions pass. Both full checks and coverage audits reject that conflict before candidate installation with exit 2, identifying the candidate, required range, and observed version. The representative must supply required peers needed only by a coordinated candidate too; these additional identities do not require separate integration suites. Absent optional peers are allowed, but present optional peers must satisfy their ranges. Another coordinated peer contributes its resolved intended version; a peer on the integration itself uses the integration's current archive version.
153
148
 
154
149
  Focused `npm test` commands remain available for exploration before advertising an upcoming version. They warn about coordinated candidates' incompatible peer ranges and still execute, while missing required peers remain setup errors. They check release intent for the selected candidate environments but do not audit every planned local release; require the full check or coverage audit as part of the release gate.
155
150
 
156
- ## Removing support
157
-
158
- `npm check` compares semantic version sets with the previous release. Narrowing a supported range, adding a new required peer, or making an optional peer required produces an end-of-support finding. Equivalent range rewrites do not. Removing a peer requirement entirely relaxes consumer constraints. Preview support is compared too. Optional-peer absence testing is deferred; making an existing optional peer required is still detected as an installation-contract change.
151
+ ## Shared release configuration
159
152
 
160
- To accept intentional support changes, provide both release intent for the integration and an explicit breaking-release policy:
153
+ Use `defineConfig` to share a release resolver and policy with public checks:
161
154
 
162
155
  ```ts
163
- const certifyBreaking: NonNullable<NpmConfig["certifyBreaking"]> = ({
164
- previousVersion,
165
- release,
166
- }) =>
167
- previousVersion.startsWith("0.")
168
- ? release.delta === "minor" || release.delta === "major"
169
- : release.delta === "major"
156
+ import { defineConfig, semverPolicy } from "breakage"
157
+ import { changesetsReleaseDelta } from "breakage/changesets"
158
+
159
+ export default defineConfig({
160
+ testPattern: "tests/public/**/*",
161
+ test: "npm run test:public",
162
+ release: {
163
+ resolveDelta: changesetsReleaseDelta(),
164
+ policy: semverPolicy({ beforeV1: "minor" }),
165
+ },
166
+ npm: {
167
+ artifact: "artifacts/integration.tgz",
168
+ representatives: "peer-environments/*",
169
+ lockfile: "package-lock.json",
170
+ prepareCommand: "npm ci --ignore-scripts --workspaces=false",
171
+ suites: {
172
+ "atom.io": {
173
+ files: ["contracts/core/**/*.ts"],
174
+ command: "node --test contracts/core/*.test.ts",
175
+ },
176
+ },
177
+ baseline: { tagPattern: "refs/tags/my-integration@" },
178
+ },
179
+ })
170
180
  ```
171
181
 
172
- This callback also receives `changes`, with each peer's previous and current declaration. It must return a boolean; throwing fails setup. Public breakage is still handled by the ordinary public command's certification. Compatibility failures against still-declared peers cannot be certified away.
182
+ `defineConfig` inherits the top-level `release` into `npm.release`; an explicit `npm.release` overrides it. Programmatic callers can pass the resulting `config.npm` to `npmCheck` or `npmTest`. Both npm and public CLI commands use the same config loader. npm paths remain relative to the config file's directory, or to `npm.packageDirectory` resolved from it.
183
+
184
+ `npm.release.resolveDelta` replaces `releaseIntent` and returns `{ previousVersion, version, delta }` or `undefined`. Resolution is cached per package and baseline context for each npm invocation. Support checks supply `baseline: { version }` from the integration's historical manifest; Breakage rejects a transition whose `previousVersion` disagrees with that baseline. Local-peer candidate checks have no historical baseline and use the transition's target version for candidate identity and coordination obligations. `changesetsReleaseDelta()` is a factory that returns the resolver, comparing the supplied baseline (or current manifest) with the planned version before Changesets versioning and the manifest version afterward. Because the manifest fallback also supplies a target for an unchanged registered local peer, using it opts those peers into candidate coverage too. A custom resolver can return `undefined` for packages that should have no local release intent. See the [release configuration guide](README.md#typescript-configuration-and-release-intent) for optional Changesets dependencies and standalone helpers.
185
+
186
+ `npm.release.policy` replaces `certifyBreaking`. The default requires a major increment; `semverPolicy({ beforeV1: "minor" })` permits pre-1.0 minor increments too. Missing evidence or a compatible version delta leaves a support drop unapproved. Invalid evidence and resolver/policy errors fail setup. Compatibility failures against still-declared peers remain failures even when the integration has a breaking release. The old `releaseIntent` and `certifyBreaking` hooks are removed and fail with migration guidance. Policies must be functions and receive `{ previousVersion, version, delta }`.
187
+
188
+ `prepareCommand`, `installCommand`, and each suite's `command` are nonempty shell strings. Preparation runs in the representative directory; installation and suites run in its disposable `test/` directory. Breakage captures stdout and stderr for reporting. All nonzero preparation/installation results fail setup; suite exit 1 denotes incompatibility and other nonzero exits denote setup failure. Functions are reserved for the release-delta resolver and policy.
189
+
190
+ ## Removing support
191
+
192
+ `npm check` compares semantic version sets with the previous release. Narrowing a supported range, adding a new required peer, or making an optional peer required produces an end-of-support finding. Equivalent range rewrites do not. Removing a peer requirement entirely relaxes consumer constraints. Preview support is compared too. Optional-peer absence testing is deferred; making an existing optional peer required is still detected as an installation-contract change.
193
+
194
+ The shared `release` model accepts intentional support changes when the integration has a breaking version delta against its previous release. Reports use support status `breaking-release` and include `{ previousVersion, version, delta, breaking }` in `support.release`. Missing intent or a compatible delta leaves support changes unapproved. Resolver or policy exceptions fail setup. Public checks use the same release model; compatibility failures against still-declared peers always remain failures.
173
195
 
174
196
  ## Installation, isolation, and recovery
175
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakage",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "description": "Run yesterday’s tests against tomorrow’s release.",
5
5
  "homepage": "https://github.com/jeremybanka/breakage#readme",
6
6
  "bugs": "https://github.com/jeremybanka/breakage/issues",
@@ -15,6 +15,17 @@
15
15
  "email": "hello@jeremybanka.com"
16
16
  },
17
17
  "type": "module",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/breakage.d.ts",
21
+ "default": "./dist/breakage.js"
22
+ },
23
+ "./changesets": {
24
+ "types": "./dist/changesets.d.ts",
25
+ "default": "./dist/changesets.js"
26
+ },
27
+ "./*": "./*"
28
+ },
18
29
  "main": "dist/breakage.js",
19
30
  "types": "dist/breakage.d.ts",
20
31
  "bin": {
@@ -26,26 +37,54 @@
26
37
  "src"
27
38
  ],
28
39
  "dependencies": {
29
- "arktype": "2.2.8",
30
- "comline": "0.9.0",
40
+ "arktype": "2.2.9",
41
+ "comline": "0.9.3",
31
42
  "minimatch": "10.2.6",
32
43
  "semver": "7.8.5",
33
44
  "simple-git": "4.0.2",
34
45
  "takua": "0.2.3"
35
46
  },
36
47
  "devDependencies": {
37
- "@types/bun": "npm:bun-types@1.4.3",
48
+ "@changesets/assemble-release-plan": "7.0.0",
49
+ "@changesets/config": "4.0.1",
50
+ "@changesets/pre": "3.0.0",
51
+ "@changesets/read": "1.0.1",
52
+ "@manypkg/get-packages": "3.1.0",
53
+ "@types/bun": "npm:bun-types@1.4.4",
38
54
  "@types/semver": "7.8.0",
39
55
  "typescript": "7.0.2"
40
56
  },
57
+ "peerDependencies": {
58
+ "@changesets/assemble-release-plan": "^7.0.0",
59
+ "@changesets/config": "^4.0.1",
60
+ "@changesets/pre": "^3.0.0",
61
+ "@changesets/read": "^1.0.1",
62
+ "@manypkg/get-packages": "^3.1.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "@changesets/assemble-release-plan": {
66
+ "optional": true
67
+ },
68
+ "@changesets/config": {
69
+ "optional": true
70
+ },
71
+ "@changesets/pre": {
72
+ "optional": true
73
+ },
74
+ "@changesets/read": {
75
+ "optional": true
76
+ },
77
+ "@manypkg/get-packages": {
78
+ "optional": true
79
+ }
80
+ },
41
81
  "publishConfig": {
42
82
  "access": "public"
43
83
  },
44
84
  "scripts": {
45
- "build": "vp pack && vp run schema",
85
+ "build": "vp pack",
46
86
  "check": "vp run -w check",
47
87
  "postversion": "dprint fmt package.json",
48
- "schema": "bun src/breakage.x.ts --outdir=dist -- schema",
49
88
  "test": "vp run test:run",
50
89
  "test:public": "BREAKAGE_CLI_ENTRYPOINT=./src/breakage.x.ts bun test -- ./tests/public",
51
90
  "test:watch": "vp run test:run --watch"
package/src/breakage.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { exec } from "node:child_process"
2
1
  import path from "node:path"
3
2
 
4
3
  import { minimatch } from "minimatch"
@@ -6,11 +5,40 @@ import { simpleGit } from "simple-git"
6
5
  import type { Chronicle } from "takua"
7
6
  import logger from "takua"
8
7
 
8
+ import { command, errorMessage } from "./task.ts"
9
+ import { readManifest, parseManifest } from "./npm/manifest.ts"
10
+ import {
11
+ resolveReleaseDelta,
12
+ versionDelta,
13
+ validateRelease,
14
+ rejectCertificationOptions,
15
+ } from "./release.ts"
16
+ import type { ReleaseConfig, VersionDelta } from "./release.ts"
9
17
  import { readBaseline } from "./baseline.ts"
10
18
  import { withDirectoryLock } from "./directory-lock.ts"
11
19
  import { latestReleaseTag as selectLatestReleaseTag } from "./release-tag.ts"
12
20
  import { withTestFileState } from "./test-file-state.ts"
13
21
 
22
+ export { defineConfig, loadConfig } from "./config.ts"
23
+ export type { BreakageConfig } from "./config.ts"
24
+ export {
25
+ semverPolicy,
26
+ versionTransition,
27
+ versionDelta,
28
+ resolveReleaseDelta,
29
+ } from "./release.ts"
30
+ export type {
31
+ ReleaseConfig,
32
+ ReleasePolicy,
33
+ ReleasePolicyFunction,
34
+ SemverPolicyOptions,
35
+ VersionTransition,
36
+ ReleaseDeltaResolver,
37
+ ReleaseDeltaContext,
38
+ VersionDelta,
39
+ } from "./release.ts"
40
+ export type { CommandResult } from "./task.ts"
41
+
14
42
  export type { BreakageBaseline, BreakagePreludeOptions } from "./baseline.ts"
15
43
  export { breakagePrelude } from "./baseline.ts"
16
44
  export { formatNpmReport, npmCheck, npmTest } from "./npm/check.ts"
@@ -22,9 +50,6 @@ export type {
22
50
  NpmSuite,
23
51
  NpmSuiteResult,
24
52
  NpmTestSelection,
25
- ReleaseContext,
26
- ReleaseEvidence,
27
- ReleaseIntent,
28
53
  SupportChange,
29
54
  } from "./npm/types.ts"
30
55
 
@@ -32,14 +57,17 @@ export type BreakageOptions = {
32
57
  tagPattern?: string | undefined
33
58
  baselineFile?: string | undefined
34
59
  testPattern: string
35
- testCommand: string
36
- certifyCommand: string
60
+ testCommand?: string
61
+ /** Shell command. Do not combine with testCommand. */
62
+ test?: string
63
+ release?: ReleaseConfig
37
64
  baseDirname?: string
38
65
  verbose?: boolean | undefined
39
66
  }
40
67
 
41
68
  export type BreakageOutcome =
42
69
  | { gitWasClean: false }
70
+ | { gitWasClean: true; setupError: string }
43
71
  | {
44
72
  gitWasClean: true
45
73
  lastReleaseFound: false
@@ -57,6 +85,7 @@ export type BreakageOutcome =
57
85
  testsWereFound: true
58
86
  testsFound: string[]
59
87
  breakingChangesFound: false
88
+ release?: VersionDelta
60
89
  }
61
90
  | {
62
91
  gitWasClean: true
@@ -66,20 +95,35 @@ export type BreakageOutcome =
66
95
  testsFound: string[]
67
96
  breakingChangesFound: true
68
97
  testResult: string
69
- breakingChangesCertified: boolean
70
- certificationStdout: string
71
- certificationStderr: string
98
+ release?: VersionDelta
72
99
  }
73
100
 
74
- export async function breakage({
75
- tagPattern,
76
- baselineFile,
77
- testPattern,
78
- testCommand,
79
- certifyCommand,
80
- baseDirname = process.cwd(),
81
- verbose = false,
82
- }: BreakageOptions): Promise<BreakageOutcome & { summary: string }> {
101
+ export async function breakage(
102
+ options: BreakageOptions,
103
+ ): Promise<BreakageOutcome & { summary: string }> {
104
+ rejectCertificationOptions(options)
105
+ const {
106
+ tagPattern,
107
+ baselineFile,
108
+ testPattern,
109
+ testCommand,
110
+ test,
111
+ release,
112
+ baseDirname = process.cwd(),
113
+ verbose = false,
114
+ } = options
115
+ if (!testPattern || (!test && !testCommand))
116
+ throw new Error(
117
+ `required arguments were not provided: testPattern and test or testCommand.`,
118
+ )
119
+ if (test !== undefined && testCommand !== undefined)
120
+ throw new Error(`Choose test or testCommand, not both.`)
121
+ if (
122
+ typeof (test ?? testCommand) !== `string` ||
123
+ !(test ?? testCommand)!.trim()
124
+ )
125
+ throw new Error(`test must be a nonempty shell command string.`)
126
+ if (release !== undefined) validateRelease(release)
83
127
  let mark: Chronicle[`mark`] | undefined
84
128
  let logMarks: Chronicle[`logMarks`] | undefined
85
129
  if (verbose) {
@@ -181,6 +225,34 @@ export async function breakage({
181
225
  )
182
226
  mark?.(`filtered to public test files`)
183
227
 
228
+ let releaseEvidence: VersionDelta | undefined
229
+ if (release) {
230
+ const current = await readManifest(path.join(baseDirname, `package.json`))
231
+ const prefix = await git.revparse([`--show-prefix`])
232
+ const previous = parseManifest(
233
+ JSON.parse(await git.show([`${releaseCommit}:${prefix}package.json`])),
234
+ `baseline`,
235
+ )
236
+ if (previous.name !== current.name)
237
+ throw new Error(`Release baseline belongs to another package.`)
238
+ const transition = await resolveReleaseDelta(release, {
239
+ packageName: current.name,
240
+ packageDirectory: path.resolve(baseDirname),
241
+ currentVersion: current.version,
242
+ baseline: {
243
+ version: previous.version,
244
+ ref: latestReleaseTag,
245
+ commit: releaseCommit,
246
+ },
247
+ })
248
+ if (transition !== undefined)
249
+ releaseEvidence = versionDelta(
250
+ transition.previousVersion,
251
+ transition.version,
252
+ release.policy,
253
+ )
254
+ }
255
+
184
256
  const prepared = await withTestFileState(git, baseDirname, async (state) => {
185
257
  if (!(await state.isClean())) {
186
258
  return {
@@ -206,61 +278,41 @@ export async function breakage({
206
278
  const { restore } = prepared
207
279
 
208
280
  try {
209
- const noBreakingChangesDetected = await new Promise<
210
- BreakageOutcome & { summary: string }
211
- >((resolve, reject) => {
212
- const result = exec(
213
- testCommand,
214
- { cwd: baseDirname },
215
- (_, __, stderr) => {
216
- if (result.exitCode === 0) {
217
- resolve({
218
- summary: `No breaking changes were detected.`,
219
- gitWasClean: true,
220
- lastReleaseFound: true,
221
- lastReleaseTag: latestReleaseTag,
222
- testsWereFound: true,
223
- testsFound: productionTestFiles,
224
- breakingChangesFound: false,
225
- })
226
- } else {
227
- reject(stderr)
228
- }
229
- },
230
- )
231
- })
232
- mark?.(`passed tests`)
233
- return noBreakingChangesDetected
234
- } catch (thrown) {
235
- mark?.(`failed tests`)
236
- const breakingChangesCertified = await new Promise<
237
- BreakageOutcome & { summary: string }
238
- >((resolve) => {
239
- const result = exec(
240
- certifyCommand,
241
- { cwd: baseDirname },
242
- (_, stdout, stderr) => {
243
- const wereCertified = result.exitCode === 0
244
- mark?.(`${wereCertified ? `passed` : `failed`} certification`)
245
- resolve({
246
- summary: wereCertified
247
- ? `Breaking changes were found and certified.`
248
- : `Breaking changes were found, but not certified.`,
249
- gitWasClean: true,
250
- lastReleaseFound: true,
251
- lastReleaseTag: latestReleaseTag,
252
- testsWereFound: true,
253
- testsFound: productionTestFiles,
254
- breakingChangesFound: true,
255
- testResult: thrown as string,
256
- breakingChangesCertified: wereCertified,
257
- certificationStdout: stdout,
258
- certificationStderr: stderr,
259
- })
260
- },
261
- )
262
- })
263
- return breakingChangesCertified
281
+ const result = await command(test ?? testCommand!, baseDirname)
282
+ if (result.code > 1)
283
+ return {
284
+ gitWasClean: true,
285
+ setupError: result.output,
286
+ summary: `Public test setup failed (${result.code}).`,
287
+ }
288
+ const common = {
289
+ gitWasClean: true as const,
290
+ lastReleaseFound: true as const,
291
+ lastReleaseTag: latestReleaseTag,
292
+ testsWereFound: true as const,
293
+ testsFound: productionTestFiles,
294
+ ...(releaseEvidence ? { release: releaseEvidence } : {}),
295
+ }
296
+ if (result.code === 0)
297
+ return {
298
+ ...common,
299
+ breakingChangesFound: false,
300
+ summary: `No breaking changes were detected.`,
301
+ }
302
+ return {
303
+ ...common,
304
+ breakingChangesFound: true,
305
+ testResult: result.output,
306
+ summary: releaseEvidence?.breaking
307
+ ? `Breaking changes are covered by release ${releaseEvidence.previousVersion} → ${releaseEvidence.version}.`
308
+ : `Breaking changes require a breaking release.`,
309
+ }
310
+ } catch (error) {
311
+ return {
312
+ gitWasClean: true,
313
+ setupError: errorMessage(error),
314
+ summary: `Public test setup failed: ${errorMessage(error)}`,
315
+ }
264
316
  } finally {
265
317
  await restore()
266
318
  mark?.(`restored original public tests`)