@rainy-updates/cli 0.4.4 → 0.5.0-rc.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,74 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.5.0-rc.2] - 2026-02-27
6
+
7
+ ### Added
8
+
9
+ - New fix-PR automation flags for CI branch workflows:
10
+ - `--fix-pr`
11
+ - `--fix-branch <name>`
12
+ - `--fix-commit-message <text>`
13
+ - `--fix-dry-run`
14
+ - `--no-pr-report`
15
+ - New summary metadata for fix-PR execution:
16
+ - `fixPrApplied`
17
+ - `fixBranchName`
18
+ - `fixCommitSha`
19
+ - New GitHub output values for fix-PR state:
20
+ - `fix_pr_applied`
21
+ - `fix_pr_branch`
22
+ - `fix_pr_commit`
23
+ - Added command-specific help output for `check --help`.
24
+
25
+ ### Changed
26
+
27
+ - `check --fix-pr` now executes update application flow to support branch+commit automation without requiring `upgrade`.
28
+ - Default PR report path is auto-assigned when `--fix-pr` is enabled: `.artifacts/deps-report.md`.
29
+ - CLI path-like options are resolved against the final effective `--cwd` value (stable behavior when option order varies).
30
+ - Workspace discovery now supports recursive patterns (`**`) and negated patterns (`!pattern`) with safer directory traversal defaults.
31
+ - Registry resolution now loads `.npmrc` scope mappings (`@scope:registry=...`) from user and project config.
32
+
33
+ ### Fixed
34
+
35
+ - Prevented stale output contracts by writing fix-PR metadata into JSON/GitHub/SARIF artifact flow after git automation is resolved.
36
+
37
+ ### Tests
38
+
39
+ - Added parser tests for fix-PR flags and final-cwd path resolution.
40
+ - Added workspace discovery coverage for recursive and negated patterns.
41
+ - Added fix-PR dry-run workflow test in temporary git repos.
42
+ - Extended GitHub output tests for new fix-PR keys.
43
+
44
+ ## [0.5.0-rc.1] - 2026-02-27
45
+
46
+ ### Added
47
+
48
+ - New CI rollout controls:
49
+ - `--fail-on none|patch|minor|major|any`
50
+ - `--max-updates <n>`
51
+ - New baseline workflow command:
52
+ - `baseline --save --file <path>` to snapshot dependency state
53
+ - `baseline --check --file <path>` to detect dependency drift
54
+ - New `init-ci --mode enterprise` template:
55
+ - Node runtime matrix (`20`, `22`)
56
+ - stricter default permissions
57
+ - artifact retention policy
58
+ - built-in rollout gate flags (`--fail-on`, `--max-updates`)
59
+
60
+ ### Changed
61
+
62
+ - Dependency target selection now evaluates available package versions from registry metadata, improving `patch|minor|major` accuracy.
63
+ - CLI parser now rejects unknown options and missing option values with explicit errors (safer CI behavior).
64
+ - SARIF output now reports the actual package version dynamically.
65
+
66
+ ### Tests
67
+
68
+ - Added baseline snapshot/diff tests.
69
+ - Added enterprise workflow generation tests.
70
+ - Added semver target selection tests using available version sets.
71
+ - Added parser tests for baseline command, rollout flags, and unknown option rejection.
72
+
5
73
  ## [0.4.4] - 2026-02-27
6
74
 
7
75
  ### Changed
package/README.md CHANGED
@@ -25,6 +25,7 @@ pnpm add -D @rainy-updates/cli
25
25
  - `check`: analyze dependencies and report available updates.
26
26
  - `upgrade`: rewrite dependency ranges in manifests, optionally install lockfile updates.
27
27
  - `warm-cache`: prefetch package metadata for fast and offline checks.
28
+ - `baseline`: save and compare dependency baseline snapshots.
28
29
 
29
30
  ## Quick usage
30
31
 
@@ -38,9 +39,16 @@ npx @rainy-updates/cli check --workspace --ci --format json --json-file .artifac
38
39
  # 3) Apply upgrades with workspace sync
39
40
  npx @rainy-updates/cli upgrade --target latest --workspace --sync --install
40
41
 
42
+ # 3b) Generate a fix branch + commit for CI automation
43
+ npx @rainy-updates/cli check --workspace --fix-pr --fix-branch chore/rainy-updates
44
+
41
45
  # 4) Warm cache for deterministic offline checks
42
46
  npx @rainy-updates/cli warm-cache --workspace --concurrency 32
43
47
  npx @rainy-updates/cli check --workspace --offline --ci
48
+
49
+ # 5) Save and compare baseline drift in CI
50
+ npx @rainy-updates/cli baseline --save --file .artifacts/deps-baseline.json --workspace
51
+ npx @rainy-updates/cli baseline --check --file .artifacts/deps-baseline.json --workspace --ci
44
52
  ```
45
53
 
46
54
  ## What it does in production
@@ -108,7 +116,7 @@ Generate a workflow in the target project automatically:
108
116
 
109
117
  ```bash
110
118
  # strict mode (recommended)
111
- npx @rainy-updates/cli init-ci --mode strict --schedule weekly
119
+ npx @rainy-updates/cli init-ci --mode enterprise --schedule weekly
112
120
 
113
121
  # lightweight mode
114
122
  npx @rainy-updates/cli init-ci --mode minimal --schedule daily
@@ -121,6 +129,7 @@ Generated file:
121
129
  Modes:
122
130
 
123
131
  - `strict`: warm-cache + offline check + artifacts + SARIF upload.
132
+ - `enterprise`: strict checks + runtime matrix + retention policy + rollout gates.
124
133
  - `minimal`: fast check-only workflow for quick adoption.
125
134
 
126
135
  Schedule:
@@ -140,12 +149,19 @@ Schedule:
140
149
  - `--concurrency <n>`
141
150
  - `--cache-ttl <seconds>`
142
151
  - `--offline`
152
+ - `--fail-on none|patch|minor|major|any`
153
+ - `--max-updates <n>`
143
154
  - `--policy-file <path>`
144
155
  - `--format table|json|minimal|github`
145
156
  - `--json-file <path>`
146
157
  - `--github-output <path>`
147
158
  - `--sarif-file <path>`
148
159
  - `--pr-report-file <path>`
160
+ - `--fix-pr`
161
+ - `--fix-branch <name>`
162
+ - `--fix-commit-message <text>`
163
+ - `--fix-dry-run`
164
+ - `--no-pr-report`
149
165
  - `--ci`
150
166
 
151
167
  ### Upgrade-only
@@ -154,6 +170,12 @@ Schedule:
154
170
  - `--pm auto|npm|pnpm`
155
171
  - `--sync`
156
172
 
173
+ ### Baseline-only
174
+
175
+ - `--save`
176
+ - `--check`
177
+ - `--file <path>`
178
+
157
179
  ## Config support
158
180
 
159
181
  Configuration can be loaded from:
@@ -175,6 +197,7 @@ rainy-updates --version
175
197
  - Node.js 20+ runtime.
176
198
  - Works with npm and pnpm workflows.
177
199
  - Uses optional `undici` pool path for high-throughput HTTP.
200
+ - Reads `.npmrc` default and scoped registries for private package environments.
178
201
  - Cache-first architecture for speed and resilience.
179
202
 
180
203
  ## CI/CD included
package/dist/bin/cli.js CHANGED
@@ -8,6 +8,8 @@ import { check } from "../core/check.js";
8
8
  import { upgrade } from "../core/upgrade.js";
9
9
  import { warmCache } from "../core/warm-cache.js";
10
10
  import { initCiWorkflow } from "../core/init-ci.js";
11
+ import { diffBaseline, saveBaseline } from "../core/baseline.js";
12
+ import { applyFixPr } from "../core/fix-pr.js";
11
13
  import { renderResult } from "../output/format.js";
12
14
  import { writeGitHubOutput } from "../output/github.js";
13
15
  import { createSarifReport } from "../output/sarif.js";
@@ -34,22 +36,47 @@ async function main() {
34
36
  : `CI workflow already exists at ${workflow.path}. Use --force to overwrite.\n`);
35
37
  return;
36
38
  }
37
- const result = parsed.command === "upgrade"
38
- ? await upgrade(parsed.options)
39
- : parsed.command === "warm-cache"
40
- ? await warmCache(parsed.options)
41
- : await check(parsed.options);
42
- const rendered = renderResult(result, parsed.options.format);
43
- process.stdout.write(rendered + "\n");
44
- if (parsed.options.jsonFile) {
45
- await fs.mkdir(path.dirname(parsed.options.jsonFile), { recursive: true });
46
- await fs.writeFile(parsed.options.jsonFile, JSON.stringify(result, null, 2) + "\n", "utf8");
39
+ if (parsed.command === "baseline") {
40
+ if (parsed.options.action === "save") {
41
+ const saved = await saveBaseline(parsed.options);
42
+ process.stdout.write(`Saved baseline at ${saved.filePath} (${saved.entries} entries)\n`);
43
+ return;
44
+ }
45
+ const diff = await diffBaseline(parsed.options);
46
+ const changes = diff.added.length + diff.removed.length + diff.changed.length;
47
+ if (changes === 0) {
48
+ process.stdout.write(`No baseline drift detected (${diff.filePath}).\n`);
49
+ return;
50
+ }
51
+ process.stdout.write(`Baseline drift detected (${diff.filePath}).\n`);
52
+ if (diff.added.length > 0) {
53
+ process.stdout.write(`Added: ${diff.added.length}\n`);
54
+ }
55
+ if (diff.removed.length > 0) {
56
+ process.stdout.write(`Removed: ${diff.removed.length}\n`);
57
+ }
58
+ if (diff.changed.length > 0) {
59
+ process.stdout.write(`Changed: ${diff.changed.length}\n`);
60
+ }
61
+ process.exitCode = 1;
62
+ return;
47
63
  }
64
+ const result = await runCommand(parsed);
48
65
  if (parsed.options.prReportFile) {
49
66
  const markdown = renderPrReport(result);
50
67
  await fs.mkdir(path.dirname(parsed.options.prReportFile), { recursive: true });
51
68
  await fs.writeFile(parsed.options.prReportFile, markdown + "\n", "utf8");
52
69
  }
70
+ if (parsed.options.fixPr && (parsed.command === "check" || parsed.command === "upgrade")) {
71
+ const fixResult = await applyFixPr(parsed.options, result, parsed.options.prReportFile ? [parsed.options.prReportFile] : []);
72
+ result.summary.fixPrApplied = fixResult.applied;
73
+ result.summary.fixBranchName = fixResult.branchName;
74
+ result.summary.fixCommitSha = fixResult.commitSha;
75
+ }
76
+ if (parsed.options.jsonFile) {
77
+ await fs.mkdir(path.dirname(parsed.options.jsonFile), { recursive: true });
78
+ await fs.writeFile(parsed.options.jsonFile, JSON.stringify(result, null, 2) + "\n", "utf8");
79
+ }
53
80
  if (parsed.options.githubOutputFile) {
54
81
  await writeGitHubOutput(parsed.options.githubOutputFile, result);
55
82
  }
@@ -58,13 +85,9 @@ async function main() {
58
85
  await fs.mkdir(path.dirname(parsed.options.sarifFile), { recursive: true });
59
86
  await fs.writeFile(parsed.options.sarifFile, JSON.stringify(sarif, null, 2) + "\n", "utf8");
60
87
  }
61
- if (parsed.options.ci && result.updates.length > 0) {
62
- process.exitCode = 1;
63
- return;
64
- }
65
- if (result.errors.length > 0) {
66
- process.exitCode = 2;
67
- }
88
+ const rendered = renderResult(result, parsed.options.format);
89
+ process.stdout.write(rendered + "\n");
90
+ process.exitCode = resolveExitCode(result, parsed.options.failOn, parsed.options.maxUpdates, parsed.options.ci);
68
91
  }
69
92
  catch (error) {
70
93
  process.stderr.write(`rainy-updates: ${String(error)}\n`);
@@ -74,6 +97,34 @@ async function main() {
74
97
  void main();
75
98
  function renderHelp(command) {
76
99
  const isCommand = command && !command.startsWith("-");
100
+ if (isCommand && command === "check") {
101
+ return `rainy-updates check [options]
102
+
103
+ Detect available dependency updates.
104
+
105
+ Options:
106
+ --workspace
107
+ --target patch|minor|major|latest
108
+ --filter <pattern>
109
+ --reject <pattern>
110
+ --dep-kinds deps,dev,optional,peer
111
+ --concurrency <n>
112
+ --cache-ttl <seconds>
113
+ --policy-file <path>
114
+ --offline
115
+ --fix-pr
116
+ --fix-branch <name>
117
+ --fix-commit-message <text>
118
+ --fix-dry-run
119
+ --no-pr-report
120
+ --json-file <path>
121
+ --github-output <path>
122
+ --sarif-file <path>
123
+ --pr-report-file <path>
124
+ --fail-on none|patch|minor|major|any
125
+ --max-updates <n>
126
+ --ci`;
127
+ }
77
128
  if (isCommand && command === "warm-cache") {
78
129
  return `rainy-updates warm-cache [options]
79
130
 
@@ -106,6 +157,11 @@ Options:
106
157
  --target patch|minor|major|latest
107
158
  --policy-file <path>
108
159
  --concurrency <n>
160
+ --fix-pr
161
+ --fix-branch <name>
162
+ --fix-commit-message <text>
163
+ --fix-dry-run
164
+ --no-pr-report
109
165
  --json-file <path>
110
166
  --pr-report-file <path>`;
111
167
  }
@@ -117,8 +173,21 @@ Create a GitHub Actions workflow template at:
117
173
 
118
174
  Options:
119
175
  --force
120
- --mode minimal|strict
176
+ --mode minimal|strict|enterprise
121
177
  --schedule weekly|daily|off`;
178
+ }
179
+ if (isCommand && command === "baseline") {
180
+ return `rainy-updates baseline [options]
181
+
182
+ Save or compare dependency baseline snapshots.
183
+
184
+ Options:
185
+ --save
186
+ --check
187
+ --file <path>
188
+ --workspace
189
+ --dep-kinds deps,dev,optional,peer
190
+ --ci`;
122
191
  }
123
192
  return `rainy-updates <command> [options]
124
193
 
@@ -127,6 +196,7 @@ Commands:
127
196
  upgrade Apply updates to manifests
128
197
  warm-cache Warm local cache for fast/offline checks
129
198
  init-ci Scaffold GitHub Actions workflow
199
+ baseline Save/check dependency baseline snapshots
130
200
 
131
201
  Global options:
132
202
  --cwd <path>
@@ -138,6 +208,13 @@ Global options:
138
208
  --sarif-file <path>
139
209
  --pr-report-file <path>
140
210
  --policy-file <path>
211
+ --fail-on none|patch|minor|major|any
212
+ --max-updates <n>
213
+ --fix-pr
214
+ --fix-branch <name>
215
+ --fix-commit-message <text>
216
+ --fix-dry-run
217
+ --no-pr-report
141
218
  --concurrency <n>
142
219
  --cache-ttl <seconds>
143
220
  --offline
@@ -145,6 +222,24 @@ Global options:
145
222
  --help, -h
146
223
  --version, -v`;
147
224
  }
225
+ async function runCommand(parsed) {
226
+ if (parsed.command === "upgrade") {
227
+ return await upgrade(parsed.options);
228
+ }
229
+ if (parsed.command === "warm-cache") {
230
+ return await warmCache(parsed.options);
231
+ }
232
+ if (parsed.options.fixPr) {
233
+ const upgradeOptions = {
234
+ ...parsed.options,
235
+ install: false,
236
+ packageManager: "auto",
237
+ sync: false,
238
+ };
239
+ return await upgrade(upgradeOptions);
240
+ }
241
+ return await check(parsed.options);
242
+ }
148
243
  async function readPackageVersion() {
149
244
  const currentFile = fileURLToPath(import.meta.url);
150
245
  const packageJsonPath = path.resolve(path.dirname(currentFile), "../../package.json");
@@ -152,3 +247,22 @@ async function readPackageVersion() {
152
247
  const parsed = JSON.parse(content);
153
248
  return parsed.version ?? "0.0.0";
154
249
  }
250
+ function resolveExitCode(result, failOn, maxUpdates, ciMode) {
251
+ if (result.errors.length > 0)
252
+ return 2;
253
+ if (typeof maxUpdates === "number" && result.updates.length > maxUpdates)
254
+ return 1;
255
+ const effectiveFailOn = failOn && failOn !== "none" ? failOn : ciMode ? "any" : "none";
256
+ if (!shouldFailForUpdates(result.updates, effectiveFailOn))
257
+ return 0;
258
+ return 1;
259
+ }
260
+ function shouldFailForUpdates(updates, failOn) {
261
+ if (failOn === "none")
262
+ return false;
263
+ if (failOn === "any" || failOn === "patch")
264
+ return updates.length > 0;
265
+ if (failOn === "minor")
266
+ return updates.some((update) => update.diffType === "minor" || update.diffType === "major");
267
+ return updates.some((update) => update.diffType === "major");
268
+ }
@@ -5,5 +5,5 @@ export declare class VersionCache {
5
5
  static create(customPath?: string): Promise<VersionCache>;
6
6
  getValid(packageName: string, target: TargetLevel): Promise<CachedVersion | null>;
7
7
  getAny(packageName: string, target: TargetLevel): Promise<CachedVersion | null>;
8
- set(packageName: string, target: TargetLevel, latestVersion: string, ttlSeconds: number): Promise<void>;
8
+ set(packageName: string, target: TargetLevel, latestVersion: string, availableVersions: string[], ttlSeconds: number): Promise<void>;
9
9
  }
@@ -9,7 +9,13 @@ class FileCacheStore {
9
9
  async get(packageName, target) {
10
10
  const entries = await this.readEntries();
11
11
  const key = this.getKey(packageName, target);
12
- return entries[key] ?? null;
12
+ const entry = entries[key];
13
+ if (!entry)
14
+ return null;
15
+ return {
16
+ ...entry,
17
+ availableVersions: Array.isArray(entry.availableVersions) ? entry.availableVersions : [entry.latestVersion],
18
+ };
13
19
  }
14
20
  async set(entry) {
15
21
  const entries = await this.readEntries();
@@ -39,31 +45,63 @@ class SqliteCacheStore {
39
45
  package_name TEXT NOT NULL,
40
46
  target TEXT NOT NULL,
41
47
  latest_version TEXT NOT NULL,
48
+ available_versions TEXT NOT NULL,
42
49
  fetched_at INTEGER NOT NULL,
43
50
  ttl_seconds INTEGER NOT NULL,
44
51
  PRIMARY KEY (package_name, target)
45
52
  );
46
53
  `);
54
+ this.ensureSchema();
47
55
  }
48
56
  async get(packageName, target) {
49
- const row = this.db
50
- .prepare(`SELECT package_name, target, latest_version, fetched_at, ttl_seconds FROM versions WHERE package_name = ? AND target = ?`)
51
- .get(packageName, target);
57
+ let row;
58
+ try {
59
+ row = this.db
60
+ .prepare(`SELECT package_name, target, latest_version, available_versions, fetched_at, ttl_seconds FROM versions WHERE package_name = ? AND target = ?`)
61
+ .get(packageName, target);
62
+ }
63
+ catch {
64
+ row = this.db
65
+ .prepare(`SELECT package_name, target, latest_version, fetched_at, ttl_seconds FROM versions WHERE package_name = ? AND target = ?`)
66
+ .get(packageName, target);
67
+ }
52
68
  if (!row)
53
69
  return null;
54
70
  return {
55
71
  packageName: row.package_name,
56
72
  target: row.target,
57
73
  latestVersion: row.latest_version,
74
+ availableVersions: parseJsonArray(row.available_versions ?? row.latest_version, row.latest_version),
58
75
  fetchedAt: row.fetched_at,
59
76
  ttlSeconds: row.ttl_seconds,
60
77
  };
61
78
  }
62
79
  async set(entry) {
63
- this.db
64
- .prepare(`INSERT OR REPLACE INTO versions (package_name, target, latest_version, fetched_at, ttl_seconds)
65
- VALUES (?, ?, ?, ?, ?)`)
66
- .run(entry.packageName, entry.target, entry.latestVersion, entry.fetchedAt, entry.ttlSeconds);
80
+ try {
81
+ this.db
82
+ .prepare(`INSERT OR REPLACE INTO versions (package_name, target, latest_version, available_versions, fetched_at, ttl_seconds)
83
+ VALUES (?, ?, ?, ?, ?, ?)`)
84
+ .run(entry.packageName, entry.target, entry.latestVersion, JSON.stringify(entry.availableVersions), entry.fetchedAt, entry.ttlSeconds);
85
+ }
86
+ catch {
87
+ this.db
88
+ .prepare(`INSERT OR REPLACE INTO versions (package_name, target, latest_version, fetched_at, ttl_seconds)
89
+ VALUES (?, ?, ?, ?, ?)`)
90
+ .run(entry.packageName, entry.target, entry.latestVersion, entry.fetchedAt, entry.ttlSeconds);
91
+ }
92
+ }
93
+ ensureSchema() {
94
+ try {
95
+ const columns = this.db.prepare("PRAGMA table_info(versions);").all();
96
+ const hasAvailableVersions = columns.some((column) => column.name === "available_versions");
97
+ if (!hasAvailableVersions) {
98
+ this.db.exec("ALTER TABLE versions ADD COLUMN available_versions TEXT;");
99
+ }
100
+ this.db.exec("UPDATE versions SET available_versions = latest_version WHERE available_versions IS NULL;");
101
+ }
102
+ catch {
103
+ // Best-effort migration.
104
+ }
67
105
  }
68
106
  }
69
107
  export class VersionCache {
@@ -92,11 +130,12 @@ export class VersionCache {
92
130
  async getAny(packageName, target) {
93
131
  return this.store.get(packageName, target);
94
132
  }
95
- async set(packageName, target, latestVersion, ttlSeconds) {
133
+ async set(packageName, target, latestVersion, availableVersions, ttlSeconds) {
96
134
  await this.store.set({
97
135
  packageName,
98
136
  target,
99
137
  latestVersion,
138
+ availableVersions,
100
139
  fetchedAt: Date.now(),
101
140
  ttlSeconds,
102
141
  });
@@ -115,3 +154,17 @@ async function tryCreateSqliteStore(dbPath) {
115
154
  }
116
155
  return null;
117
156
  }
157
+ function parseJsonArray(raw, fallback) {
158
+ if (typeof raw !== "string")
159
+ return [fallback];
160
+ try {
161
+ const parsed = JSON.parse(raw);
162
+ if (!Array.isArray(parsed))
163
+ return [fallback];
164
+ const values = parsed.filter((value) => typeof value === "string");
165
+ return values.length > 0 ? values : [fallback];
166
+ }
167
+ catch {
168
+ return [fallback];
169
+ }
170
+ }
@@ -1,4 +1,4 @@
1
- import type { DependencyKind, OutputFormat, TargetLevel } from "../types/index.js";
1
+ import type { DependencyKind, FailOnLevel, OutputFormat, TargetLevel } from "../types/index.js";
2
2
  export interface FileConfig {
3
3
  target?: TargetLevel;
4
4
  filter?: string;
@@ -15,6 +15,13 @@ export interface FileConfig {
15
15
  offline?: boolean;
16
16
  policyFile?: string;
17
17
  prReportFile?: string;
18
+ failOn?: FailOnLevel;
19
+ maxUpdates?: number;
20
+ fixPr?: boolean;
21
+ fixBranch?: string;
22
+ fixCommitMessage?: string;
23
+ fixDryRun?: boolean;
24
+ noPrReport?: boolean;
18
25
  install?: boolean;
19
26
  packageManager?: "auto" | "npm" | "pnpm";
20
27
  sync?: boolean;
@@ -0,0 +1,23 @@
1
+ import type { BaselineOptions, DependencyKind } from "../types/index.js";
2
+ interface BaselineEntry {
3
+ packagePath: string;
4
+ kind: DependencyKind;
5
+ name: string;
6
+ range: string;
7
+ }
8
+ export interface BaselineSaveResult {
9
+ filePath: string;
10
+ entries: number;
11
+ }
12
+ export interface BaselineDiffResult {
13
+ filePath: string;
14
+ added: BaselineEntry[];
15
+ removed: BaselineEntry[];
16
+ changed: Array<{
17
+ before: BaselineEntry;
18
+ after: BaselineEntry;
19
+ }>;
20
+ }
21
+ export declare function saveBaseline(options: BaselineOptions): Promise<BaselineSaveResult>;
22
+ export declare function diffBaseline(options: BaselineOptions): Promise<BaselineDiffResult>;
23
+ export {};
@@ -0,0 +1,72 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { collectDependencies, readManifest } from "../parsers/package-json.js";
4
+ import { discoverPackageDirs } from "../workspace/discover.js";
5
+ export async function saveBaseline(options) {
6
+ const entries = await collectBaselineEntries(options.cwd, options.workspace, options.includeKinds);
7
+ const payload = {
8
+ version: 1,
9
+ createdAt: new Date().toISOString(),
10
+ entries,
11
+ };
12
+ await fs.mkdir(path.dirname(options.filePath), { recursive: true });
13
+ await fs.writeFile(options.filePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
14
+ return {
15
+ filePath: options.filePath,
16
+ entries: entries.length,
17
+ };
18
+ }
19
+ export async function diffBaseline(options) {
20
+ const content = await fs.readFile(options.filePath, "utf8");
21
+ const baseline = JSON.parse(content);
22
+ const currentEntries = await collectBaselineEntries(options.cwd, options.workspace, options.includeKinds);
23
+ const baselineMap = new Map(baseline.entries.map((entry) => [toKey(entry), entry]));
24
+ const currentMap = new Map(currentEntries.map((entry) => [toKey(entry), entry]));
25
+ const added = [];
26
+ const removed = [];
27
+ const changed = [];
28
+ for (const [key, current] of currentMap) {
29
+ const base = baselineMap.get(key);
30
+ if (!base) {
31
+ added.push(current);
32
+ continue;
33
+ }
34
+ if (base.range !== current.range) {
35
+ changed.push({ before: base, after: current });
36
+ }
37
+ }
38
+ for (const [key, base] of baselineMap) {
39
+ if (!currentMap.has(key)) {
40
+ removed.push(base);
41
+ }
42
+ }
43
+ return {
44
+ filePath: options.filePath,
45
+ added: sortEntries(added),
46
+ removed: sortEntries(removed),
47
+ changed: changed.sort((a, b) => toKey(a.after).localeCompare(toKey(b.after))),
48
+ };
49
+ }
50
+ async function collectBaselineEntries(cwd, workspace, includeKinds) {
51
+ const packageDirs = await discoverPackageDirs(cwd, workspace);
52
+ const entries = [];
53
+ for (const packageDir of packageDirs) {
54
+ const manifest = await readManifest(packageDir);
55
+ const deps = collectDependencies(manifest, includeKinds);
56
+ for (const dep of deps) {
57
+ entries.push({
58
+ packagePath: path.relative(cwd, packageDir) || ".",
59
+ kind: dep.kind,
60
+ name: dep.name,
61
+ range: dep.range,
62
+ });
63
+ }
64
+ }
65
+ return sortEntries(entries);
66
+ }
67
+ function toKey(entry) {
68
+ return `${entry.packagePath}::${entry.kind}::${entry.name}`;
69
+ }
70
+ function sortEntries(entries) {
71
+ return [...entries].sort((a, b) => toKey(a).localeCompare(toKey(b)));
72
+ }