@krapwoo/ds-viewer 0.8.0 → 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.
- package/dist/cli/dependencyBumpProof.js +216 -0
- package/dist/cli/update.js +20 -1
- package/dist/cli/updatePlan.js +67 -11
- package/package.json +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// cli/dependencyBumpProof.ts
|
|
2
|
+
/** Conservative proof that a dirty `package.json` or npm `package-lock.json` is *solely* a
|
|
3
|
+
* viewer-only dependency bump to the version already installed — i.e. a previously-started
|
|
4
|
+
* update that the running process itself already finished. Every check here fails closed:
|
|
5
|
+
* malformed JSON, an unsupported lockfile shape, a missing side, or any other difference at all
|
|
6
|
+
* leaves the file classified unsafe (still dirty). Pure functions only — `updatePlan.ts` owns
|
|
7
|
+
* reading `HEAD` and the working tree. Every comparison here is against *parsed* JSON, not raw
|
|
8
|
+
* text: formatting-only and key-ordering differences are never treated as a difference, and
|
|
9
|
+
* duplicate keys in either side collapse to their last occurrence, exactly as `JSON.parse` does. */
|
|
10
|
+
import { compareVersions, parseVersion } from "./semver.js";
|
|
11
|
+
const PACKAGE_NAME = '@krapwoo/ds-viewer';
|
|
12
|
+
function isPlainObject(value) {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function deepEqual(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return true;
|
|
18
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
19
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
20
|
+
}
|
|
21
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
22
|
+
const aKeys = Object.keys(a);
|
|
23
|
+
return aKeys.length === Object.keys(b).length && aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
/** Every leaf (or missing-key) difference between two parsed JSON values, each named by its full
|
|
28
|
+
* key path. An empty result means the two values are deeply equal. */
|
|
29
|
+
function diffJson(head, working, path = []) {
|
|
30
|
+
if (deepEqual(head, working))
|
|
31
|
+
return [];
|
|
32
|
+
if (isPlainObject(head) && isPlainObject(working)) {
|
|
33
|
+
const diffs = [];
|
|
34
|
+
for (const key of new Set([...Object.keys(head), ...Object.keys(working)])) {
|
|
35
|
+
const inHead = Object.prototype.hasOwnProperty.call(head, key);
|
|
36
|
+
const inWorking = Object.prototype.hasOwnProperty.call(working, key);
|
|
37
|
+
if (inHead && !inWorking)
|
|
38
|
+
diffs.push({ path: [...path, key], type: 'removed' });
|
|
39
|
+
else if (!inHead && inWorking)
|
|
40
|
+
diffs.push({ path: [...path, key], type: 'added', workingValue: working[key] });
|
|
41
|
+
else
|
|
42
|
+
diffs.push(...diffJson(head[key], working[key], [...path, key]));
|
|
43
|
+
}
|
|
44
|
+
return diffs;
|
|
45
|
+
}
|
|
46
|
+
return [{ path, type: 'changed', workingValue: working }];
|
|
47
|
+
}
|
|
48
|
+
/** The three registry forms `update` (and a prior run of it) ever writes for an exact target
|
|
49
|
+
* version — never a local/path/workspace spec, which `isLocalSpec` elsewhere already refuses to
|
|
50
|
+
* update over in the first place. */
|
|
51
|
+
function isAcceptedRegistryForm(spec, currentVersion) {
|
|
52
|
+
return spec === currentVersion || spec === `^${currentVersion}` || spec === `~${currentVersion}`;
|
|
53
|
+
}
|
|
54
|
+
/** Whether a HEAD spec names a version strictly older than `currentVersion` — the proof that the
|
|
55
|
+
* working spec is a genuine forward bump, not a same-version range edit (e.g. HEAD `"^0.4.0"` →
|
|
56
|
+
* working `"0.4.0"` at `currentVersion` `"0.4.0"`, which names the same version both sides).
|
|
57
|
+
* Fails closed on anything other than a parseable exact/caret/tilde registry form. */
|
|
58
|
+
function isOlderRegistryForm(spec, currentVersion) {
|
|
59
|
+
if (typeof spec !== 'string')
|
|
60
|
+
return false;
|
|
61
|
+
const exact = spec.replace(/^[\^~]/, '');
|
|
62
|
+
try {
|
|
63
|
+
return compareVersions(parseVersion(exact), parseVersion(currentVersion)) === -1;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function headValueAtPath(head, path) {
|
|
70
|
+
let cursor = head;
|
|
71
|
+
for (const key of path) {
|
|
72
|
+
if (!isPlainObject(cursor))
|
|
73
|
+
return undefined;
|
|
74
|
+
cursor = cursor[key];
|
|
75
|
+
}
|
|
76
|
+
return cursor;
|
|
77
|
+
}
|
|
78
|
+
/** Safe only when `workingRaw` differs from `headRaw` solely at one existing `dependencies` or
|
|
79
|
+
* `devDependencies` entry for `@krapwoo/ds-viewer`, and that entry's working value names
|
|
80
|
+
* `currentVersion` in an accepted registry form. */
|
|
81
|
+
export function isSafePackageJsonBump(headRaw, workingRaw, currentVersion) {
|
|
82
|
+
let head;
|
|
83
|
+
let working;
|
|
84
|
+
try {
|
|
85
|
+
head = JSON.parse(headRaw);
|
|
86
|
+
working = JSON.parse(workingRaw);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (!isPlainObject(head) || !isPlainObject(working))
|
|
92
|
+
return false;
|
|
93
|
+
const diffs = diffJson(head, working);
|
|
94
|
+
if (diffs.length !== 1)
|
|
95
|
+
return false;
|
|
96
|
+
const [diff] = diffs;
|
|
97
|
+
if (diff.type !== 'changed' || diff.path.length !== 2)
|
|
98
|
+
return false;
|
|
99
|
+
const [section, name] = diff.path;
|
|
100
|
+
if (name !== PACKAGE_NAME)
|
|
101
|
+
return false;
|
|
102
|
+
if (section !== 'dependencies' && section !== 'devDependencies')
|
|
103
|
+
return false;
|
|
104
|
+
if (!isOlderRegistryForm(headValueAtPath(head, diff.path), currentVersion))
|
|
105
|
+
return false;
|
|
106
|
+
return isAcceptedRegistryForm(diff.workingValue, currentVersion);
|
|
107
|
+
}
|
|
108
|
+
function redactKey(value, keyPath) {
|
|
109
|
+
const clone = JSON.parse(JSON.stringify(value));
|
|
110
|
+
let cursor = clone;
|
|
111
|
+
for (let i = 0; i < keyPath.length - 1; i += 1) {
|
|
112
|
+
const next = cursor[keyPath[i]];
|
|
113
|
+
if (!isPlainObject(next))
|
|
114
|
+
return clone;
|
|
115
|
+
cursor = next;
|
|
116
|
+
}
|
|
117
|
+
delete cursor[keyPath[keyPath.length - 1]];
|
|
118
|
+
return clone;
|
|
119
|
+
}
|
|
120
|
+
/** Which section (`dependencies`/`devDependencies`) the lockfile root (`packages[""]`, design's
|
|
121
|
+
* own npm v7+ shape) declares `@krapwoo/ds-viewer` in, and that its working value is an accepted
|
|
122
|
+
* registry form of `currentVersion` — `undefined` for any ambiguity (declared in neither or both
|
|
123
|
+
* sides, mismatched sections, or anything other than a plain string on either side). */
|
|
124
|
+
function safeRootDeclarationSection(headRoot, workingRoot, currentVersion) {
|
|
125
|
+
for (const section of ['dependencies', 'devDependencies']) {
|
|
126
|
+
const other = section === 'dependencies' ? 'devDependencies' : 'dependencies';
|
|
127
|
+
const headValue = isPlainObject(headRoot[section]) ? headRoot[section][PACKAGE_NAME] : undefined;
|
|
128
|
+
const workingValue = isPlainObject(workingRoot[section]) ? workingRoot[section][PACKAGE_NAME] : undefined;
|
|
129
|
+
if (headValue === undefined && workingValue === undefined)
|
|
130
|
+
continue;
|
|
131
|
+
if (typeof headValue !== 'string' || typeof workingValue !== 'string')
|
|
132
|
+
return undefined;
|
|
133
|
+
const headOther = isPlainObject(headRoot[other]) ? headRoot[other][PACKAGE_NAME] : undefined;
|
|
134
|
+
const workingOther = isPlainObject(workingRoot[other]) ? workingRoot[other][PACKAGE_NAME] : undefined;
|
|
135
|
+
if (headOther !== undefined || workingOther !== undefined)
|
|
136
|
+
return undefined; // declared in both sections somewhere — ambiguous.
|
|
137
|
+
return isAcceptedRegistryForm(workingValue, currentVersion) && isOlderRegistryForm(headValue, currentVersion) ? section : undefined;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
function isSafeLockfileV1Bump(head, working, currentVersion) {
|
|
142
|
+
const headDeps = head.dependencies;
|
|
143
|
+
const workingDeps = working.dependencies;
|
|
144
|
+
if (!isPlainObject(headDeps) || !isPlainObject(workingDeps))
|
|
145
|
+
return false;
|
|
146
|
+
const headEntry = headDeps[PACKAGE_NAME];
|
|
147
|
+
const workingEntry = workingDeps[PACKAGE_NAME];
|
|
148
|
+
if (!isPlainObject(headEntry) || !isPlainObject(workingEntry))
|
|
149
|
+
return false;
|
|
150
|
+
if (workingEntry.version !== currentVersion)
|
|
151
|
+
return false;
|
|
152
|
+
const redactedHead = redactKey(head, ['dependencies', PACKAGE_NAME]);
|
|
153
|
+
const redactedWorking = redactKey(working, ['dependencies', PACKAGE_NAME]);
|
|
154
|
+
return deepEqual(redactedHead, redactedWorking);
|
|
155
|
+
}
|
|
156
|
+
function isSafeLockfileV2Or3Bump(head, working, currentVersion) {
|
|
157
|
+
const nodeModulesKey = `node_modules/${PACKAGE_NAME}`;
|
|
158
|
+
const headPackages = head.packages;
|
|
159
|
+
const workingPackages = working.packages;
|
|
160
|
+
if (!isPlainObject(headPackages) || !isPlainObject(workingPackages))
|
|
161
|
+
return false;
|
|
162
|
+
const headEntry = headPackages[nodeModulesKey];
|
|
163
|
+
const workingEntry = workingPackages[nodeModulesKey];
|
|
164
|
+
if (!isPlainObject(headEntry) || !isPlainObject(workingEntry))
|
|
165
|
+
return false;
|
|
166
|
+
if (workingEntry.version !== currentVersion)
|
|
167
|
+
return false;
|
|
168
|
+
const headRoot = headPackages[''];
|
|
169
|
+
const workingRoot = workingPackages[''];
|
|
170
|
+
if (!isPlainObject(headRoot) || !isPlainObject(workingRoot))
|
|
171
|
+
return false;
|
|
172
|
+
const section = safeRootDeclarationSection(headRoot, workingRoot, currentVersion);
|
|
173
|
+
if (!section)
|
|
174
|
+
return false;
|
|
175
|
+
// npm v7/v8's legacy mirror tree, kept alongside `packages` for backward-compat tooling — absent
|
|
176
|
+
// in v9+'s v3 lockfiles. Unlike the two checks above, redacting this unconditionally (without
|
|
177
|
+
// first proving its own working value) would let an untracked edit to this entry hide behind an
|
|
178
|
+
// otherwise-safe bump.
|
|
179
|
+
const workingLegacy = isPlainObject(working.dependencies) ? working.dependencies[PACKAGE_NAME] : undefined;
|
|
180
|
+
if (workingLegacy !== undefined && (!isPlainObject(workingLegacy) || workingLegacy.version !== currentVersion))
|
|
181
|
+
return false;
|
|
182
|
+
let redactedHead = redactKey(head, ['packages', nodeModulesKey]);
|
|
183
|
+
let redactedWorking = redactKey(working, ['packages', nodeModulesKey]);
|
|
184
|
+
redactedHead = redactKey(redactedHead, ['packages', '', section, PACKAGE_NAME]);
|
|
185
|
+
redactedWorking = redactKey(redactedWorking, ['packages', '', section, PACKAGE_NAME]);
|
|
186
|
+
redactedHead = redactKey(redactedHead, ['dependencies', PACKAGE_NAME]);
|
|
187
|
+
redactedWorking = redactKey(redactedWorking, ['dependencies', PACKAGE_NAME]);
|
|
188
|
+
return deepEqual(redactedHead, redactedWorking);
|
|
189
|
+
}
|
|
190
|
+
/** Safe only when valid JSON differs from `HEAD` solely in the root declaration for
|
|
191
|
+
* `@krapwoo/ds-viewer` and the package's own lock entry, both resolving `currentVersion`.
|
|
192
|
+
* Supports lockfileVersion 1 (the single `dependencies` entry doubles as both) and 2/3 (the
|
|
193
|
+
* `packages[""]` root declaration plus the `packages["node_modules/..."]` entry). Any other
|
|
194
|
+
* `lockfileVersion`, or a mismatch between `HEAD` and the working tree, is an unsupported shape —
|
|
195
|
+
* fails closed. */
|
|
196
|
+
export function isSafeNpmLockfileBump(headRaw, workingRaw, currentVersion) {
|
|
197
|
+
let head;
|
|
198
|
+
let working;
|
|
199
|
+
try {
|
|
200
|
+
head = JSON.parse(headRaw);
|
|
201
|
+
working = JSON.parse(workingRaw);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
if (!isPlainObject(head) || !isPlainObject(working))
|
|
207
|
+
return false;
|
|
208
|
+
const lockfileVersion = working.lockfileVersion;
|
|
209
|
+
if (head.lockfileVersion !== lockfileVersion)
|
|
210
|
+
return false;
|
|
211
|
+
if (lockfileVersion === 1)
|
|
212
|
+
return isSafeLockfileV1Bump(head, working, currentVersion);
|
|
213
|
+
if (lockfileVersion === 2 || lockfileVersion === 3)
|
|
214
|
+
return isSafeLockfileV2Or3Bump(head, working, currentVersion);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
package/dist/cli/update.js
CHANGED
|
@@ -51,7 +51,8 @@ export function localInstallRefusal(spec, latest) {
|
|
|
51
51
|
/** Design §5 "`npx ds-viewer update`", steps 1-6. */
|
|
52
52
|
export async function runUpdate(config, options = {}) {
|
|
53
53
|
const currentVersion = options.currentVersion ?? readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
|
|
54
|
-
const
|
|
54
|
+
const buildPlan = options.buildPlan ?? buildUpdatePlan;
|
|
55
|
+
let plan = await buildPlan(config, currentVersion);
|
|
55
56
|
if ('error' in plan)
|
|
56
57
|
return { output: plan.error, exitCode: isAlreadyUpToDate(plan.error) ? 0 : 1 };
|
|
57
58
|
const planText = formatPlanHuman(plan);
|
|
@@ -78,6 +79,24 @@ export async function runUpdate(config, options = {}) {
|
|
|
78
79
|
const proceed = await (options.confirm ?? defaultConfirm)(`${planText}\n\nUpdate to ${plan.latest}? [y/N] `);
|
|
79
80
|
if (!proceed)
|
|
80
81
|
return { output: 'Aborted: nothing was changed.', exitCode: 0 };
|
|
82
|
+
// The prompt can sit open for a while — a file could have changed, or a newer version could
|
|
83
|
+
// have appeared, in the meantime. Revalidate immediately before the one side effect (install)
|
|
84
|
+
// this function ever runs, rather than trusting the plan the user approved. `--yes` has no
|
|
85
|
+
// prompt to sit open behind, so it deliberately skips this and uses the plan already built
|
|
86
|
+
// above (frozen scope: "may use the initial immediately preceding plan").
|
|
87
|
+
const revalidated = await buildPlan(config, currentVersion);
|
|
88
|
+
if ('error' in revalidated) {
|
|
89
|
+
if (isAlreadyUpToDate(revalidated.error))
|
|
90
|
+
return { output: revalidated.error, exitCode: 0 };
|
|
91
|
+
return { output: "Couldn't re-check the plan before installing. Nothing was changed.", exitCode: 1 };
|
|
92
|
+
}
|
|
93
|
+
if (revalidated.latest !== plan.latest || (!options.force && revalidated.dirtyFiles.length > 0)) {
|
|
94
|
+
return {
|
|
95
|
+
output: 'The plan changed while confirming — a file changed, or a newer version appeared. Nothing was changed. Run update again to see the new plan.',
|
|
96
|
+
exitCode: 1,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
plan = revalidated;
|
|
81
100
|
}
|
|
82
101
|
const execImpl = options.execImpl ?? ((cmd, args, opts) => {
|
|
83
102
|
const run = platformCommand(cmd, args);
|
package/dist/cli/updatePlan.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// cli/updatePlan.ts
|
|
2
2
|
import { execFile, execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { detectPackageManager, platformCommand } from "./packageManager.js";
|
|
8
|
+
import { isSafeNpmLockfileBump, isSafePackageJsonBump } from "./dependencyBumpProof.js";
|
|
8
9
|
import { listKitFileDiffs } from "./kitDiff.js";
|
|
9
10
|
import { extractSummaryBullets, fetchWithTimeout } from "./updateCheck.js";
|
|
10
11
|
import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
|
|
@@ -20,20 +21,56 @@ function lockfileName(pm) {
|
|
|
20
21
|
return 'yarn.lock';
|
|
21
22
|
return 'package-lock.json';
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* this plan's own `files` list names), never the old one (Important finding, Fable correction
|
|
26
|
-
* pass: `R old -> new` was previously read as the single literal path `"old -> new"`, which
|
|
27
|
-
* could never match a planned file). */
|
|
28
|
-
function parseDirtyPaths(porcelain) {
|
|
29
|
-
return new Set(porcelain
|
|
24
|
+
function parseDirtyEntries(porcelain) {
|
|
25
|
+
return porcelain
|
|
30
26
|
.split('\n')
|
|
31
27
|
.filter((line) => line.length > 3)
|
|
32
28
|
.map((line) => {
|
|
29
|
+
const code = line.slice(0, 2);
|
|
33
30
|
const raw = line.slice(3).trim();
|
|
34
31
|
const arrow = raw.indexOf(' -> ');
|
|
35
|
-
return arrow === -1 ? raw : raw.slice(arrow + 4);
|
|
36
|
-
})
|
|
32
|
+
return { path: arrow === -1 ? raw : raw.slice(arrow + 4), code };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Only a plain modification, staged or not (`" M"`, `"M "`, `"MM"`) — never an add, delete,
|
|
36
|
+
* rename, or untracked file (`"A "`, `"D "`, `"R "`, `"??"`, …), which the content-only proof
|
|
37
|
+
* below can't always distinguish from a genuine edit on its own. */
|
|
38
|
+
function isPlainModification(code) {
|
|
39
|
+
return code !== ' ' && /^[ M]{2}$/.test(code);
|
|
40
|
+
}
|
|
41
|
+
// Exported for the integration test only (verifies the real git/filesystem behavior for a
|
|
42
|
+
// subdirectory project); every other caller goes through `BuildPlanOptions`.
|
|
43
|
+
export function defaultGitShow(projectRoot, filePath) {
|
|
44
|
+
try {
|
|
45
|
+
// `<rev>:<path>` is resolved relative to the *repository root* — only a `./`-prefixed path is
|
|
46
|
+
// resolved relative to `cwd` (`projectRoot`), which is what `filePath` (already stripped to be
|
|
47
|
+
// project-relative) needs for any project in a subdirectory.
|
|
48
|
+
return execFileSync('git', ['show', `HEAD:./${filePath}`], { cwd: projectRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function defaultReadWorkingFile(projectRoot, filePath) {
|
|
55
|
+
try {
|
|
56
|
+
return readFileSync(path.join(projectRoot, filePath), 'utf8');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Frozen scope's one safe case: a dirty `package.json`/npm `package-lock.json` that provably
|
|
63
|
+
* differs from `HEAD` solely by a prior, already-finished bump to `currentVersion` (the viewer
|
|
64
|
+
* version this very process is running). Every other package manager's lockfile stays
|
|
65
|
+
* conservative — unproven, and so left dirty. */
|
|
66
|
+
function isSafeSupersededDependencyFile(filePath, pm, currentVersion, headRaw, workingRaw) {
|
|
67
|
+
if (headRaw === undefined || workingRaw === undefined)
|
|
68
|
+
return false;
|
|
69
|
+
if (filePath === 'package.json')
|
|
70
|
+
return isSafePackageJsonBump(headRaw, workingRaw, currentVersion);
|
|
71
|
+
if (pm === 'npm' && filePath === lockfileName(pm))
|
|
72
|
+
return isSafeNpmLockfileBump(headRaw, workingRaw, currentVersion);
|
|
73
|
+
return false;
|
|
37
74
|
}
|
|
38
75
|
/** Strips the repository-root prefix `git rev-parse --show-prefix` reports from every path in a
|
|
39
76
|
* `git status --porcelain` listing (both sides of a rename), so the result reads relative to
|
|
@@ -106,6 +143,8 @@ export async function buildUpdatePlan(config, currentVersion, options = {}) {
|
|
|
106
143
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
107
144
|
const tmpDirImpl = options.tmpDirImpl ?? (() => mkdtempSync(path.join(tmpdir(), 'ds-viewer-update-')));
|
|
108
145
|
const gitStatusImpl = options.gitStatusImpl ?? defaultGitStatus;
|
|
146
|
+
const gitShowImpl = options.gitShowImpl ?? defaultGitShow;
|
|
147
|
+
const readWorkingFileImpl = options.readWorkingFileImpl ?? defaultReadWorkingFile;
|
|
109
148
|
// Important finding, Fable correction pass: a real, unreachable registry otherwise hangs `npm
|
|
110
149
|
// view`/`npm pack` for minutes (observed: "Checking…" sat for 140s, with `/update/status` not
|
|
111
150
|
// answering at all in that time — this whole function runs inside the endpoint's own request
|
|
@@ -180,9 +219,26 @@ export async function buildUpdatePlan(config, currentVersion, options = {}) {
|
|
|
180
219
|
return { error: `Couldn't check for uncommitted changes (git): ${error.message}` };
|
|
181
220
|
}
|
|
182
221
|
const outsideGitRepo = porcelain === undefined;
|
|
183
|
-
const
|
|
222
|
+
const dirtyEntries = porcelain === undefined ? [] : parseDirtyEntries(porcelain);
|
|
223
|
+
const dirtyPaths = new Set(dirtyEntries.map((entry) => entry.path));
|
|
184
224
|
for (const file of files)
|
|
185
225
|
file.dirty = dirtyPaths.has(file.path);
|
|
226
|
+
// Frozen scope: a dirty `package.json`/npm lockfile is resolved back to clean only when it
|
|
227
|
+
// provably differs from `HEAD` solely by a prior, already-finished bump to `currentVersion` —
|
|
228
|
+
// never for an add, delete, rename, or untracked file, which `isPlainModification` rules out
|
|
229
|
+
// before the content proof is even attempted.
|
|
230
|
+
const codeByPath = new Map(dirtyEntries.map((entry) => [entry.path, entry.code]));
|
|
231
|
+
for (const file of files) {
|
|
232
|
+
if (!file.dirty || (file.path !== 'package.json' && file.path !== lockfileName(pm)))
|
|
233
|
+
continue;
|
|
234
|
+
const code = codeByPath.get(file.path);
|
|
235
|
+
if (!code || !isPlainModification(code))
|
|
236
|
+
continue;
|
|
237
|
+
const headRaw = gitShowImpl(config.projectRoot, file.path);
|
|
238
|
+
const workingRaw = readWorkingFileImpl(config.projectRoot, file.path);
|
|
239
|
+
if (isSafeSupersededDependencyFile(file.path, pm, currentVersion, headRaw, workingRaw))
|
|
240
|
+
file.dirty = false;
|
|
241
|
+
}
|
|
186
242
|
let summary = [];
|
|
187
243
|
let releasedAt;
|
|
188
244
|
try {
|