|
| 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