Skip to content

Commit 7127b48

Browse files
baozhoutaoclaude
andauthored
fix(cloud-connection,cli): LocalManifestSource.list() reports the ledger entries it could not read (#5413) (#5424)
A truncated / unreadable / unparseable file under `.objectstack/installed-packages/` was dropped in an un-bound per-file `catch` and `list()` returned a bare array, so a short list was indistinguishable from a complete one: no difference in the return value, no log, no count. All three consumers gave a confidently wrong answer — `rehydrate()` left the installed app unregistered (gone from the app switcher, its objects nonexistent) with nothing in the log, `handleList()` served the console a list that looked whole with `success: true`, and `os doctor` printed a clean `✓ Unique scope` over manifests it had never parsed. Skipping a corrupt file stays correct — one bad manifest must not stop a runtime booting the packages that are fine. Skipping it SILENTLY was the defect. `list()` now returns `{ entries, skipped }` (option A of the issue's decision point): reporting is the caller's job, and "I read only half the ledger" becomes a fact in the type rather than an absence. Enumerating the DIRECTORY still throws — a different fact from "some files in it would not parse", and #5412 already reports the two as separate rows. Wiring, per triage: - `rehydrate()` warns per skipped file, before the empty-entries early return (an all-corrupt ledger is the worst case, not the exempt one), naming the file, the consequence and the thrower's own words. - `handleList()` logs the same; the WIRE SHAPE is deliberately unchanged — putting the skip in the response body is a separate schema decision. - `os doctor` turns `skipped` into a `Unique scope` warning row and withholds the `✓` success line, alongside the directory-level row from #5412. #5414's `⚠ SCOPE BOUNDARY` test went red exactly as its own comment predicted and is rewritten as the positive assertion. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 20963e7 commit 7127b48

8 files changed

Lines changed: 742 additions & 69 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/cloud-connection': minor
3+
'@objectstack/cli': patch
4+
---
5+
6+
`LocalManifestSource.list()` now reports the ledger entries it could NOT read
7+
8+
A truncated, unreadable or unparseable file under
9+
`.objectstack/installed-packages/` was skipped in an un-bound per-file `catch`
10+
and `list()` returned a bare array, so a short list was indistinguishable from a
11+
complete one — no difference in the return value, no log, no count. Three
12+
consumers gave a confidently wrong answer: the installed app was never
13+
registered at boot (gone from the app switcher, its objects nonexistent) with
14+
nothing in the log, the console's installed-apps list came back short with
15+
`success: true`, and `os doctor` printed `✓ Unique scope` over manifests it had
16+
never parsed.
17+
18+
Skipping a corrupt file stays correct — one bad manifest must not stop a runtime
19+
booting the packages that are fine. Skipping it *silently* was the defect.
20+
21+
**Breaking (`@objectstack/cloud-connection`):** `LocalManifestSource.list()`
22+
returns `{ entries, skipped }` instead of `InstalledManifestEntry[]`.
23+
24+
- FROM: `const entries = source.list();`
25+
- TO: `const { entries, skipped } = source.list();`
26+
27+
`skipped` is `Array< { file: string; cause: unknown } >` — the file's basename
28+
and the object reading or parsing it threw, unwrapped. Callers that only want
29+
the old behaviour read `.entries`; the point of the shape is that dropping
30+
`skipped` is now something a caller has to do on purpose. Two new exported
31+
types, `InstalledManifestListing` and `SkippedManifestEntry`.
32+
33+
Enumerating the ledger DIRECTORY still throws out of `list()` — unchanged, and a
34+
different fact from "some files in it would not parse".
35+
36+
`os doctor` reports unparseable entries as a `Unique scope` warning row naming
37+
each file with its cause, and withholds the `` success line, alongside the
38+
directory-level row it already had.

packages/cli/src/commands/doctor-ledger-read-failure.test.ts

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,23 @@
2727
*
2828
* A false PASS, on the one constraint the `isolated` posture makes dangerous.
2929
*
30-
* ── Scope boundary this file also pins ───────────────────────────────────
30+
* ── The second half, one layer down (#5413) ──────────────────────────────
3131
*
32-
* The issue's stated repro — a truncated JSON entry inside the ledger — does
33-
* NOT reach that `catch`, and #5412's fix does not change it.
34-
* `LocalManifestSource.list()` skips unparseable files in its own per-file
35-
* `catch` (`packages/cloud-connection/src/local-manifest-source.ts`), so a
36-
* corrupt entry is dropped inside the PRODUCER and `list()` returns a short
37-
* list indistinguishable from a complete one. Doctor sees a successful call.
38-
* That is a real defect of the same family one layer down, it is a
39-
* cross-package contract change to fix (filed as #5413), and it is pinned here
40-
* as a boundary (`the corrupt-entry case is NOT covered`) rather than left for
41-
* the next reader to re-derive — see that test's comment.
32+
* This file used to pin a SCOPE BOUNDARY: the issue's stated repro — a
33+
* truncated JSON entry inside the ledger — did NOT reach that `catch`, because
34+
* `LocalManifestSource.list()` skipped unparseable files in its own per-file
35+
* `catch` (`packages/cloud-connection/src/local-manifest-source.ts`) and
36+
* returned a short list indistinguishable from a complete one. Doctor saw a
37+
* successful call and printed the same false `✓ Unique scope`, over manifests
38+
* it had never parsed.
39+
*
40+
* #5413 fixed that at the PRODUCER, where it belonged — `list()` now returns
41+
* `{ entries, skipped }`, so "I read only half the ledger" is a fact in the
42+
* type rather than an absence — and doctor turns `skipped` into its own row.
43+
* The boundary case went red exactly as its comment predicted and is now the
44+
* positive assertion below. The two facts stay separately reported: the
45+
* directory could not be enumerated at all (#5412) versus it enumerated fine
46+
* and some files in it would not parse (#5413).
4247
*/
4348

4449
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -64,9 +69,12 @@ const plain = (s: string) => s.replace(SGR, '');
6469
/** The success line that must NOT appear when only half the check ran. */
6570
const CLEAN_BILL = 'No unconfirmed installation-wide uniques';
6671

67-
/** The head of the row that replaces it. */
72+
/** The head of the row that replaces it — the DIRECTORY-level failure (#5412). */
6873
const LEDGER_HEADLINE = 'Could not read the installed-package ledger';
6974

75+
/** The head of its ENTRY-level sibling (#5413). Deliberately distinct text. */
76+
const SKIPPED_HEADLINE = 'installed-package ledger entr';
77+
7078
describe('installedPackageLedgerFailureCheck — the finding the shared catch used to eat', () => {
7179
it('quotes what was thrown, in the row AND in the verbose detail', () => {
7280
const err = Object.assign(new Error("ENOTDIR: not a directory, scandir '/p/.objectstack'"), {
@@ -316,21 +324,23 @@ describe('os doctor, end to end, against an unreadable installed-package ledger'
316324
expect(run.exitCode).toBeUndefined();
317325
}, 60_000);
318326

319-
it('⚠ SCOPE BOUNDARY: a CORRUPT ENTRY is still absorbed by the producer', async () => {
320-
// This pins the issue's own stated repro as NOT FIXED, deliberately, and
321-
// records why — so the next reader does not re-derive it from scratch or
322-
// assume #5412 covered it.
323-
//
324-
// `LocalManifestSource.list()` skips unparseable files in its own per-file
325-
// `catch`, so a truncated manifest never reaches doctor's `catch`: the
326-
// call SUCCEEDS and returns a short list that is indistinguishable from a
327-
// complete one. Doctor cannot tell the difference without re-implementing
328-
// the producer's parsing rules in the consumer, which is precisely the
329-
// lenient-consumer workaround this repo forbids. The fix belongs in
330-
// `packages/cloud-connection` (a cross-package contract change to
331-
// `list()`) and is filed as #5413.
332-
//
333-
// When #5413 lands, THIS TEST GOES RED — which is the point.
327+
/**
328+
* ── Was the ⚠ SCOPE BOUNDARY case, now flipped positive (#5413) ────────
329+
*
330+
* This slot used to pin the issue's own stated repro as deliberately NOT
331+
* FIXED: a truncated entry never reached doctor's `catch` because
332+
* `LocalManifestSource.list()` skipped unparseable files in its own per-file
333+
* `catch` and returned a short list indistinguishable from a complete one.
334+
* Doctor could not have told the difference without re-implementing the
335+
* producer's parsing rules in the consumer — the lenient-consumer workaround
336+
* this repo forbids — so the fix went to the producer instead: `list()` now
337+
* returns `{ entries, skipped }` and doctor reports the second half.
338+
*
339+
* The old case asserted `not.toContain('broken')` and went red exactly as its
340+
* comment predicted. Rewritten as the positive assertion rather than deleted:
341+
* the repro is the same, only the expected verdict inverted.
342+
*/
343+
it('reports a CORRUPT ENTRY by name instead of skipping it in silence', async () => {
334344
writeConfig();
335345
fs.mkdirSync(ledgerPath(), { recursive: true });
336346
fs.writeFileSync(path.join(ledgerPath(), 'good.json'), JSON.stringify(globalUniqueEntry('good')));
@@ -342,12 +352,66 @@ describe('os doctor, end to end, against an unreadable installed-package ledger'
342352

343353
const run = await runDoctor();
344354

345-
// The readable entry is reported
355+
// The readable entry is still reported, unchanged.
346356
expect(run.out).toContain("installed package 'good'");
347-
// …and the corrupt one is silently absent, with no row of any kind naming
348-
// it. Not a passing behaviour — a pinned boundary.
349-
expect(run.out).not.toContain('broken');
357+
// ① The corrupt one is named — the row that did not exist before #5413.
358+
expect(run.out).toContain(SKIPPED_HEADLINE);
359+
expect(run.out).toContain('broken.json');
360+
// ② With the parser's own words, not a summary doctor invented.
361+
expect(run.out).toMatch(/JSON/i);
362+
// ③ Under the `Unique scope` name column, like its directory-level sibling,
363+
// so the row an operator scans for is present rather than missing.
364+
expect(run.out).toContain('Unique scope');
365+
// ④ NOT the directory-level row: the directory read fine. Two distinct
366+
// facts, two distinct headlines (#5412 vs #5413).
350367
expect(run.out).not.toContain(LEDGER_HEADLINE);
368+
// Gauge: still a warning, report finishes, exit stays 0.
369+
expect(run.out).toContain('Environment is functional but has some warnings');
370+
expect(run.exitCode).toBeUndefined();
371+
}, 60_000);
372+
373+
it('withholds the clean bill when the ONLY finding is an unparseable entry', async () => {
374+
// The false-PASS shape this issue is really about. The good entry declares
375+
// no global unique, so before #5413 the advisory found nothing to say and
376+
// printed `✓ Unique scope` — over a manifest it had never parsed. An
377+
// unreadable manifest may declare an installation-wide unique; nobody can
378+
// say it does not.
379+
writeConfig();
380+
fs.mkdirSync(ledgerPath(), { recursive: true });
381+
fs.writeFileSync(
382+
path.join(ledgerPath(), 'clean.json'),
383+
JSON.stringify({ manifestId: 'clean', manifest: { objects: [] } }),
384+
);
385+
fs.writeFileSync(path.join(ledgerPath(), 'broken.json'), '{"manifestId":"broken"');
386+
387+
const run = await runDoctor();
388+
389+
expect(run.out).not.toContain(CLEAN_BILL);
390+
expect(run.out).toContain(SKIPPED_HEADLINE);
391+
expect(run.out).toContain('broken.json');
392+
}, 60_000);
393+
394+
it('names EVERY unparseable entry, and expands the causes under --verbose', async () => {
395+
writeConfig();
396+
fs.mkdirSync(ledgerPath(), { recursive: true });
397+
fs.writeFileSync(path.join(ledgerPath(), 'one.json'), '{oops');
398+
fs.writeFileSync(path.join(ledgerPath(), 'two.json'), 'not json at all');
399+
400+
const plainRun = await runDoctor();
401+
const verboseRun = await runDoctor(['--verbose']);
402+
403+
// One row is one line, so the row quotes the first cause and counts the
404+
// rest; `fix` carries every file with its own cause.
405+
expect(plainRun.out).toContain('2 installed-package ledger entries could not be read');
406+
expect(plainRun.out).toContain('(+1 more)');
407+
expect(plainRun.out).not.toContain('cause:');
408+
409+
expect(verboseRun.out).toContain('one.json');
410+
expect(verboseRun.out).toContain('two.json');
411+
expect(verboseRun.out).toContain('cause:');
412+
// The fix is per-file, so it has to say what to do with each one.
413+
expect(verboseRun.out).toContain('Repair the JSON, or delete the file');
414+
expect(verboseRun.exitCode).toBeUndefined();
351415
}, 60_000);
352416
});
353417

0 commit comments

Comments
 (0)