@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.
@@ -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
+ }
@@ -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 plan = await (options.buildPlan ?? buildUpdatePlan)(config, currentVersion);
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);
@@ -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
- /** `git status --porcelain`'s format is `XY path` (plain) or `XY old -> new` (a rename) — the
24
- * path starts at column 3 either way; a rename's own "dirty" identity is its *new* path (the one
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 dirtyPaths = porcelain === undefined ? new Set() : parseDirtyPaths(porcelain);
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",