Skip to content

Commit 5a616d5

Browse files
Elon Muskclaude
andauthored
fix(create-objectstack): derive the "Created files" summary from the finished project, not the template copy (#10323) (#10559)
The summary was `copyDir`'s collected array, printed between the template copy and `<pm> install`. Two of the three write phases run after that point, so it could not name what they wrote. Measured against published 17.1.0 (`create-objectstack demo-app`, then a full walk of the result): printed summary entries : 12 paths written on disk : 18045 UNREACHABLE from summary: 18033 .agents/ 49 agent/ 49 .claude/ 11 .github/ 1 AGENTS.md 1 skills-lock.json 1 pnpm-lock.yaml 1 node_modules/ 17920 Two ~968 KB trees of agent instructions landed unnamed while the same run closed with "Review skills before use; they run with full agent permissions." The summary now walks the project directory once every write has landed, so it is self-correcting: a path some future dependency writes shows up with nobody editing this package. Directories over the collapse threshold become one line with path, entry count and size; the paths the skills installer created are measured by diffing the directory across the call and marked so the permissions warning points at them. Same run after the change: 20 entries printed, 0 written paths unreachable. Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent d0c0865 commit 5a616d5

5 files changed

Lines changed: 648 additions & 8 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"create-objectstack": minor
3+
---
4+
5+
`create-objectstack` now closes with a "Created files" summary derived from a
6+
walk of the finished project directory, so it names everything the run wrote —
7+
including the files written after the template copy (#10323).
8+
9+
The old summary was the template copy's own list, printed before
10+
`<pm> install` and before `npx skills add`. Measured against published
11+
`create-objectstack@17.1.0` (`create-objectstack demo-app`, then a full walk of
12+
the result): 12 entries printed, 18,045 paths on disk, **18,033 of them
13+
unreachable from the summary**`AGENTS.md`, `.github/copilot-instructions.md`,
14+
`pnpm-lock.yaml`, `skills-lock.json`, `node_modules/`, and two ~968 KB trees of
15+
agent instructions at `.agents/skills/` and `agent/skills/`.
16+
17+
That mattered because the same run ends with the `skills` CLI printing *"Review
18+
skills before use; they run with full agent permissions."* Advice to review
19+
files the run never named, at paths it never showed, is advice a newcomer
20+
cannot act on — the wrong failure direction for a security-flavoured warning.
21+
22+
The list could not have been correct where it stood: two of the three write
23+
phases belong to other processes, and the `skills` installer's destination set
24+
moves with **its** releases, not ours. Reading the directory afterwards makes
25+
the summary self-correcting instead. Large directories collapse to one line
26+
carrying their path, entry count and size, so the bulk stays reviewable without
27+
18,000 lines of output, and the paths the skills installer created are marked
28+
`⚠ skills` with the permissions warning tied to them.
29+
30+
Same run, after the change: 20 entries printed, **0 written paths unreachable**.
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// Pins the PROPERTY the closing scaffold summary exists to hold: every path
4+
// the run wrote is reachable from what the run printed — named outright, or
5+
// lying beneath a directory line that is.
6+
//
7+
// ## Why a property and not a file count
8+
//
9+
// The defect this guards against was a hard-coded-by-construction list: the
10+
// summary printed `copyDir`'s collected array, which is the template files and
11+
// nothing else, so `AGENTS.md`, `.github/copilot-instructions.md`,
12+
// `pnpm-lock.yaml`, `skills-lock.json`, `node_modules/` and two ~968 KB trees
13+
// of agent instructions were written and never named. Measured against
14+
// published `create-objectstack@17.1.0`: 12 entries printed, 18,045 paths on
15+
// disk, 18,033 of them unreachable from the summary.
16+
//
17+
// An assertion of the shape "the summary lists 40 files" would fail the moment
18+
// the template gains or loses a file, and would be re-baselined rather than
19+
// investigated — which is the exact mechanism that produced the stale 12. So
20+
// nothing below counts files. Each case builds a tree, summarizes it, and
21+
// asserts reachability over whatever that tree happens to contain.
22+
//
23+
// ## Why synthetic trees rather than a real scaffold
24+
//
25+
// The real run's last two write phases are `<pm> install` and
26+
// `npx skills add …` — a package manager and a third-party CLI, both needing
27+
// the network. A unit test that depended on them would be a network test that
28+
// fails for reasons unrelated to this property. The shapes that matter are
29+
// reproduced directly instead: a large tree that must collapse, a symlink farm
30+
// that must be counted without being followed, a single-child chain that must
31+
// compress, and a tree past the measurement budget.
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
import fs from 'node:fs';
35+
import os from 'node:os';
36+
import path from 'node:path';
37+
38+
import {
39+
summarizeTree,
40+
unreachablePaths,
41+
describeEntry,
42+
formatBytes,
43+
COLLAPSE_AT,
44+
MEASURE_BUDGET,
45+
} from './created-summary.js';
46+
47+
let root: string;
48+
49+
/** The published catalog as measured — `Found 11 skills` in the real run. */
50+
const SKILLS = [
51+
'objectstack-ai',
52+
'objectstack-api',
53+
'objectstack-automation',
54+
'objectstack-data',
55+
'objectstack-formula',
56+
'objectstack-i18n',
57+
'objectstack-platform',
58+
'objectstack-pm-dispatch',
59+
'objectstack-query',
60+
'objectstack-ui',
61+
'objectstack-upgrade',
62+
];
63+
64+
/** Every file and symlink under `dir`, project-relative — what the summary must cover. */
65+
function walkWritten(dir: string, rel = '', out: string[] = []): string[] {
66+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
67+
const r = rel ? `${rel}/${entry.name}` : entry.name;
68+
if (entry.isSymbolicLink()) out.push(r);
69+
else if (entry.isDirectory()) walkWritten(path.join(dir, entry.name), r, out);
70+
else out.push(r);
71+
}
72+
return out;
73+
}
74+
75+
function write(rel: string, contents: string) {
76+
const abs = path.join(root, rel);
77+
fs.mkdirSync(path.dirname(abs), { recursive: true });
78+
fs.writeFileSync(abs, contents);
79+
}
80+
81+
beforeAll(() => {
82+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'created-summary-'));
83+
84+
// The shape of a real scaffold, reproduced without the network.
85+
// Phase 1 — template copy + identity rewrite + agent guides.
86+
for (const f of [
87+
'.dockerignore',
88+
'.gitignore',
89+
'AGENTS.md',
90+
'Dockerfile',
91+
'README.md',
92+
'docker-compose.yml',
93+
'objectstack.config.ts',
94+
'objectstack.manifest.json',
95+
'package.json',
96+
'pnpm-workspace.yaml',
97+
'tsconfig.json',
98+
]) {
99+
write(f, `${f}\n`);
100+
}
101+
write('.github/copilot-instructions.md', 'copilot\n');
102+
write('src/objects/index.ts', 'export {};\n');
103+
write('src/objects/note.object.ts', 'export {};\n');
104+
105+
// Phase 2 — the package manager.
106+
write('pnpm-lock.yaml', 'lockfileVersion: 9.0\n'.repeat(400));
107+
for (let i = 0; i < MEASURE_BUDGET + 50; i += 1) {
108+
write(`node_modules/pkg-${i}/index.js`, 'module.exports = {};\n');
109+
}
110+
111+
// Phase 3 — the skills installer: two real trees plus a symlink farm, the
112+
// layout measured from `npx skills add … --all` (11 skills, 49 real files
113+
// per tree, `.claude/skills/*` symlinked into `.agents/skills/`). The COUNT
114+
// is faithful on purpose — a 3-skill fixture sits under COLLAPSE_AT and
115+
// would exercise the enumerate path while the real tree takes the collapse
116+
// path, testing the branch the product does not use.
117+
write('skills-lock.json', '{"version":1}\n');
118+
for (const skill of SKILLS) {
119+
for (const tree of ['.agents/skills', 'agent/skills']) {
120+
write(`${tree}/${skill}/SKILL.md`, '---\nname: x\n---\n'.repeat(60));
121+
write(`${tree}/${skill}/references/guide.md`, 'guide\n'.repeat(60));
122+
}
123+
fs.mkdirSync(path.join(root, '.claude/skills'), { recursive: true });
124+
fs.symlinkSync(
125+
path.join('..', '..', '.agents', 'skills', skill),
126+
path.join(root, '.claude/skills', skill),
127+
);
128+
}
129+
});
130+
131+
afterAll(() => {
132+
fs.rmSync(root, { recursive: true, force: true });
133+
});
134+
135+
describe('created-summary — reachability', () => {
136+
// Vacuity guard. Every assertion below is quantified over the tree, so a
137+
// test that summarized the wrong directory would assert nothing at all and
138+
// stay green. This proves the fixture really was built and really was read.
139+
it('reads a tree with all three write phases in it', () => {
140+
const written = walkWritten(root);
141+
expect(written).toContain('AGENTS.md');
142+
expect(written).toContain('skills-lock.json');
143+
expect(written).toContain('pnpm-lock.yaml');
144+
expect(written.filter((p) => p.startsWith('.agents/')).length).toBeGreaterThan(0);
145+
expect(written.filter((p) => p.startsWith('agent/')).length).toBeGreaterThan(0);
146+
expect(written.filter((p) => p.startsWith('node_modules/')).length).toBeGreaterThan(
147+
MEASURE_BUDGET,
148+
);
149+
expect(summarizeTree(root).length).toBeGreaterThan(0);
150+
});
151+
152+
// THE property. Not "the summary is long enough" — every single written
153+
// path is reachable, whatever the tree happens to hold.
154+
it('names, or covers by an ancestor line, every path on disk', () => {
155+
const written = walkWritten(root);
156+
const missed = unreachablePaths(summarizeTree(root), written);
157+
expect(
158+
missed,
159+
`The scaffold summary would not disclose ${missed.length} written path(s), ` +
160+
`e.g. ${missed.slice(0, 5).join(', ')}. Every path the run writes must be ` +
161+
'reachable from what it prints — a path nobody was shown is a path nobody ' +
162+
'can review.',
163+
).toEqual([]);
164+
});
165+
166+
// The regression in its original form: the files written after the template
167+
// copy are exactly the ones the old summary could not see.
168+
it('discloses the post-copy writes the old list structurally could not', () => {
169+
const entries = summarizeTree(root);
170+
const missed = unreachablePaths(entries, [
171+
'AGENTS.md',
172+
'.github/copilot-instructions.md',
173+
'pnpm-lock.yaml',
174+
'skills-lock.json',
175+
'.agents/skills/objectstack-ai/SKILL.md',
176+
'agent/skills/objectstack-ai/SKILL.md',
177+
'.claude/skills/objectstack-ai',
178+
'node_modules/pkg-0/index.js',
179+
]);
180+
expect(missed).toEqual([]);
181+
});
182+
183+
it('is not vacuous — a path the summary does not cover is reported', () => {
184+
// Without this, `unreachablePaths` returning `[]` unconditionally would
185+
// make every assertion above pass while proving nothing.
186+
const missed = unreachablePaths(summarizeTree(root), ['not-written-by-anyone.txt']);
187+
expect(missed).toEqual(['not-written-by-anyone.txt']);
188+
});
189+
});
190+
191+
describe('created-summary — readability', () => {
192+
it('collapses big trees instead of enumerating them', () => {
193+
const entries = summarizeTree(root);
194+
// 11 skills x 2 files x 2 trees plus a 2050-entry node_modules: an
195+
// enumeration would be thousands of lines. The bar is reachability AND a
196+
// summary a human reads, so bulk arrives as directory lines.
197+
expect(entries.length).toBeLessThan(60);
198+
const dirs = entries.filter((e) => e.kind === 'dir').map((e) => e.path);
199+
expect(dirs).toContain('node_modules/');
200+
});
201+
202+
it('compresses single-child chains down to the directory worth opening', () => {
203+
// `.agents/` holds only `skills/`, so the line must read `.agents/skills/`
204+
// — the path the "review your skills" advice actually sends people to.
205+
const dirs = summarizeTree(root)
206+
.filter((e) => e.kind === 'dir')
207+
.map((e) => e.path);
208+
expect(dirs).toContain('.agents/skills/');
209+
expect(dirs).toContain('agent/skills/');
210+
expect(dirs).not.toContain('.agents/');
211+
});
212+
213+
it('counts symlinks without following them', () => {
214+
// `.claude/skills/*` are symlinks into `.agents/skills/`. Following them
215+
// would double-count that tree and report a size the disk does not hold.
216+
const claude = summarizeTree(root).find((e) => e.path === '.claude/skills/');
217+
const agents = summarizeTree(root).find((e) => e.path === '.agents/skills/');
218+
expect(claude, '.claude/skills/ must appear as its own line').toBeTruthy();
219+
expect(claude!.entries).toBe(SKILLS.length);
220+
expect(claude!.bytes).toBeLessThan(agents!.bytes);
221+
});
222+
223+
it('reports a lower bound rather than a wrong number past the budget', () => {
224+
const nm = summarizeTree(root).find((e) => e.path === 'node_modules/')!;
225+
expect(nm.truncated).toBe(true);
226+
expect(describeEntry(nm)).toMatch(/^over [\d,]+ files$/);
227+
// A truncated entry must not print a size: the walk stopped early, so any
228+
// byte total it carries is a fraction presented as a whole.
229+
expect(describeEntry(nm)).not.toMatch(/KB|MB|B$/);
230+
});
231+
232+
it('describes a fully measured directory with both count and size', () => {
233+
const skills = summarizeTree(root).find((e) => e.path === '.agents/skills/')!;
234+
expect(skills.truncated).toBe(false);
235+
expect(describeEntry(skills)).toMatch(/^\d+ files, [\d.]+ (B|KB|MB)$/);
236+
});
237+
238+
it('enumerates small directories file by file', () => {
239+
const paths = summarizeTree(root).map((e) => e.path);
240+
expect(paths).toContain('src/objects/note.object.ts');
241+
expect(paths).toContain('.github/copilot-instructions.md');
242+
expect(paths).not.toContain('src/');
243+
});
244+
245+
it('formats byte counts at each magnitude', () => {
246+
expect(formatBytes(46)).toBe('46 B');
247+
expect(formatBytes(4837)).toBe('4.7 KB');
248+
expect(formatBytes(991232)).toBe('968 KB');
249+
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
250+
});
251+
252+
it('agrees with its own collapse threshold', () => {
253+
// Pins the rule, not a number: a directory at the threshold is
254+
// enumerated, one past it collapses.
255+
const small = fs.mkdtempSync(path.join(os.tmpdir(), 'summary-small-'));
256+
try {
257+
for (let i = 0; i < COLLAPSE_AT; i += 1) {
258+
fs.mkdirSync(path.join(small, 'many'), { recursive: true });
259+
fs.writeFileSync(path.join(small, 'many', `f${i}.txt`), 'x');
260+
}
261+
expect(summarizeTree(small).every((e) => e.kind === 'file')).toBe(true);
262+
263+
fs.writeFileSync(path.join(small, 'many', 'one-more.txt'), 'x');
264+
const after = summarizeTree(small);
265+
expect(after.map((e) => e.path)).toEqual(['many/']);
266+
expect(unreachablePaths(after, walkWritten(small))).toEqual([]);
267+
} finally {
268+
fs.rmSync(small, { recursive: true, force: true });
269+
}
270+
});
271+
});

0 commit comments

Comments
 (0)