OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / e1c16b8

Decouple parser/traverse/tree from live sibling repos
`npm test` now runs end to end for the first time: 137 tests, 874 asserts, all green. It used to die partway through parser.test.js with a TypeError, which masked every test file after it. ## What was wrong Three suites read repos OUTSIDE this one — ../hyper-orgs, ../hyper-sync, ../hyper-deploy and ../pear-git-v3 — and asserted against their CURRENT content: a pinned version string, a `_room` field, a getter count. hyper-orgs went 0.2.0 -> 0.2.2 and dropped `_room`, so fifteen assertions here went red for a change that had nothing to do with codetree, and one of them dereferenced undefined and took the whole run down. This is codetree violating its own invariant I1 (docs/DOC-DEDUP-LAYER.md): "Updating a spec must never break codetree's own tests." A test pinned to another project's current state is the same mistake one level up. ## The fix test/fixtures/workspace — four small packages, ours, versioned here, that reproduce the SHAPES those tests were really checking: widget-store an ESM barrel of re-exports; a facade class with constructor JSDoc, 13 methods, 6 getters, `this._x` state, delegation to an inner room, error contracts including a template literal; entry/lib/schema/test file roles; a handler-factory file widget-rooms the dependency edge, and the second user of a shared dep legacy-sync CJS: no `type` field, require + module.exports, a class mapped-io package.json `imports` maps (fs/path/os -> bare-*) Its README records what stands in for what, and why. ## What got better, not just green The fixture is deterministic, so the defensive guards are gone: - `if (orgs)` / `if (mod)` / `if (orgDeps)` wrappers deleted — those existed because the live repos might not have the shape the test wanted. - "more than 5 modules" / "more than 10 modules" became `is(4)` plus the exact module set. - "more than 3 getters" became the exact six names in source order. - shared deps and the dependency graph are now asserted by value, not probed. - traverse gained a real assertion that a bare specifier is NOT a localImport. Also added .codetreeignore for `test/fixtures/`: without it, scanning codetree counts fixture files as its own source (46 -> 65 files) and its self-check measures them. Note the CI self-check is still `|| true` in .pear-git/ci.yaml. That is a separate gap — codetree has no spec/ of its own yet — not this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e1c16b8663972742241695997a854644d87c25cf
by plankatron <fleeky@gmail.com> · 2026-09-20T21:33:56.000Z

Changed files (18) +383 -193

.codetreeignore +4 -0
……@@ -0,0 +1,4 @@
1# Fixtures are inputs to the test suite, not part of codetree's own structure.
2# Without this, scanning codetree counts fixture files as its own source (48 ->
3# 65 files) and its self-check measures them too.
4test/fixtures/
test/fixtures/workspace/README.md +19 -0
……@@ -0,0 +1,19 @@
1# workspace — a fixture that stands in for real sibling repos
2
3`parser`, `traverse` and `tree` used to read `../hyper-orgs`, `../hyper-sync`,
4`../hyper-deploy` and `../pear-git-v3` — LIVE repos outside this one. When
5hyper-orgs went 0.2.0 -> 0.2.2 and dropped a field, fifteen assertions here went
6red for a change that had nothing to do with codetree. That is invariant I1 in
7docs/DOC-DEDUP-LAYER.md being violated by codetree's own suite.
8
9These four packages reproduce the SHAPES those tests were really checking:
10
11| package | stands in for | shape under test |
12|---|---|---|
13| `widget-store` | hyper-orgs | ESM barrel of re-exports; a big class with constructor JSDoc, many methods, getters, `this._x` state, delegation, error contracts; lib/ + schema + test file roles |
14| `widget-rooms` | hyper-rooms | the dependency edge, and a second user of a shared dep |
15| `legacy-sync` | hyper-sync | CJS: no `type` field, `module.exports`, a class |
16| `mapped-io` | hyper-deploy | package.json `imports` maps |
17| `widget-store/lib/handlers/widgets.js` | pear-git-v3 handlers | the handler-factory pattern |
18
19Keep them boring. They exist to be parsed, not to work.
test/fixtures/workspace/legacy-sync/index.js +14 -0
……@@ -0,0 +1,14 @@
1// CommonJS on purpose: no "type" field in package.json, require + module.exports.
2const path = require('path')
3
4class Syncer {
5 constructor (dir) {
6 this._dir = path.resolve(dir)
7 }
8
9 sync () {
10 return this._dir
11 }
12}
13
14module.exports = { Syncer }
test/fixtures/workspace/legacy-sync/package.json +5 -0
……@@ -0,0 +1,5 @@
1{
2 "name": "legacy-sync",
3 "version": "0.1.0",
4 "main": "index.js"
5}
test/fixtures/workspace/mapped-io/index.js +6 -0
……@@ -0,0 +1,6 @@
1import fs from 'fs'
2import path from 'path'
3
4export function readIt (p) {
5 return fs.readFileSync(path.resolve(p), 'utf-8')
6}
test/fixtures/workspace/mapped-io/package.json +11 -0
……@@ -0,0 +1,11 @@
1{
2 "name": "mapped-io",
3 "version": "0.3.0",
4 "type": "module",
5 "main": "index.js",
6 "imports": {
7 "fs": { "bare": "bare-fs", "default": "fs" },
8 "path": { "bare": "bare-path", "default": "path" },
9 "os": { "bare": "bare-os", "default": "os" }
10 }
11}
test/fixtures/workspace/widget-rooms/index.js +1 -0
……@@ -0,0 +1 @@
1export { Room } from './lib/room.js'
test/fixtures/workspace/widget-rooms/lib/room.js +19 -0
……@@ -0,0 +1,19 @@
1export class Room {
2 constructor (corestore, options) {
3 this._corestore = corestore
4 this._options = options || {}
5 }
6
7 get key () { return this._options.key || null }
8 get writable () { return true }
9
10 async ready () {}
11 async close () {}
12 async put (key, value) { return { key, value } }
13 async get (key) { return { key } }
14 async del (key) { return true }
15 async list () { return [] }
16 async member (peer) { return { peer, level: 0 } }
17 async invite (level) { return { level } }
18 async addWriter (key) { return key }
19}
test/fixtures/workspace/widget-rooms/package.json +12 -0
……@@ -0,0 +1,12 @@
1{
2 "name": "widget-rooms",
3 "version": "1.0.0",
4 "type": "module",
5 "main": "index.js",
6 "scripts": {
7 "test": "brittle test/*.test.js"
8 },
9 "dependencies": {
10 "b4a": "^1.6.0"
11 }
12}
test/fixtures/workspace/widget-store/index.js +2 -0
……@@ -0,0 +1,2 @@
1export { WidgetStore } from './lib/widget-store.js'
2export { storeSchema, RESERVED_NAMES, validateName } from './lib/schema.js'
test/fixtures/workspace/widget-store/lib/handlers/widgets.js +14 -0
……@@ -0,0 +1,14 @@
1/**
2 * The handler-factory pattern: a function returning a map of RPC handlers,
3 * standing in for pear-git-v3's lib/handlers/*.js.
4 */
5export function createWidgetHandlers (ctx) {
6 return {
7 async list () {
8 return ctx.store.list()
9 },
10 async create (req) {
11 return ctx.store.create(req.name, req.type, req.data)
12 }
13 }
14}
test/fixtures/workspace/widget-store/lib/schema.js +11 -0
……@@ -0,0 +1,11 @@
1export const storeSchema = {
2 widget: { name: 'string', kind: 'string' }
3}
4
5export const RESERVED_NAMES = ['admin', 'root', 'system']
6
7export function validateName (name) {
8 if (!name) throw new Error('Name is required')
9 if (RESERVED_NAMES.includes(name)) throw new Error(`Reserved name: ${name}`)
10 return true
11}
test/fixtures/workspace/widget-store/lib/widget-store.js +89 -0
……@@ -0,0 +1,89 @@
1import { Room } from 'widget-rooms'
2import { storeSchema, validateName } from './schema.js'
3
4/**
5 * A store of widgets, backed by a room.
6 *
7 * Stands in for hyper-orgs: a facade class that delegates lifecycle to an inner
8 * room and exposes a CRUD surface over it.
9 */
10export class WidgetStore {
11 /**
12 * Create a store.
13 * @param {object} corestore - the corestore to build on
14 * @param {object} options - room options
15 */
16 constructor (corestore, options) {
17 this._corestore = corestore
18 this._options = options || {}
19 this._room = new Room(corestore, this._options)
20 this._opened = false
21 }
22
23 get key () { return this._room.key }
24 get keyHex () { return this._room.key.toString('hex') }
25 get writable () { return this._room.writable }
26 get view () { return this._room.view }
27 get base () { return this._room.base }
28 get room () { return this._room }
29
30 async ready () {
31 await this._room.ready()
32 this._opened = true
33 }
34
35 async close () {
36 await this._room.close()
37 this._opened = false
38 }
39
40 async create (name, type, data) {
41 validateName(name)
42 if (!storeSchema[type]) throw new Error(`Unknown type: ${type}`)
43 return this._room.put(name, { type, data })
44 }
45
46 async get (name) {
47 return this._room.get(name)
48 }
49
50 async update (name, data) {
51 const existing = await this.get(name)
52 if (!existing) throw new Error(`No such widget: ${name}`)
53 return this._room.put(name, { ...existing, data })
54 }
55
56 async delete (name) {
57 return this._room.del(name)
58 }
59
60 async list () {
61 return this._room.list()
62 }
63
64 async checkAccess (peer, level) {
65 const member = await this._room.member(peer)
66 if (!member) return false
67 return member.level >= level
68 }
69
70 async addMember (peer, level) {
71 return this._room.put('member/' + peer, { level })
72 }
73
74 async addResource (name, resource) {
75 return this._room.put('resource/' + name, resource)
76 }
77
78 async createGrant (peer, scope) {
79 return this._room.put('grant/' + peer, { scope })
80 }
81
82 async createInvite (level) {
83 return this._room.invite(level)
84 }
85
86 async addWriter (key) {
87 return this._room.addWriter(key)
88 }
89}
test/fixtures/workspace/widget-store/package.json +16 -0
……@@ -0,0 +1,16 @@
1{
2 "name": "widget-store",
3 "version": "0.2.0",
4 "type": "module",
5 "main": "index.js",
6 "scripts": {
7 "test": "brittle test/*.test.js"
8 },
9 "dependencies": {
10 "widget-rooms": "^1.0.0",
11 "b4a": "^1.6.0"
12 },
13 "devDependencies": {
14 "brittle": "^3.0.0"
15 }
16}
test/fixtures/workspace/widget-store/test/store.test.js +7 -0
……@@ -0,0 +1,7 @@
1// Exists so the scanner has a file with the 'test' role to classify.
2import { test } from 'brittle'
3import { WidgetStore } from '../index.js'
4
5test('widget-store: constructs', (t) => {
6 t.ok(WidgetStore, 'class is exported')
7})
test/parser.test.js +54 -67
……@@ -5,43 +5,49 @@ import { fileURLToPath } from 'url'
55import { parseFile } from '../lib/parsers/js.js'
66
77const HERE = dirname(fileURLToPath(import.meta.url))
8const ROOT = resolve(HERE, '../../')
8
9// These tests used to read ../hyper-orgs, ../hyper-sync and ../pear-git-v3 —
10// live repos outside this one. hyper-orgs went 0.2.0 -> 0.2.2 and dropped a
11// field, and eight assertions here went red for a change that had nothing to do
12// with codetree. Invariant I1 (docs/DOC-DEDUP-LAYER.md) forbids exactly that.
13// fixtures/workspace reproduces the shapes; see its README for the mapping.
14const FIXTURE = resolve(HERE, 'fixtures/workspace')
915
1016function readSource (rel) {
11 return readFileSync(resolve(ROOT, rel), 'utf-8')
17 return readFileSync(resolve(FIXTURE, rel), 'utf-8')
1218}
1319
14test('parser: hyper-orgs/index.js re-exports', (t) => {
15 const source = readSource('hyper-orgs/index.js')
16 const result = parseFile(source, 'hyper-orgs/index.js')
20test('parser: a barrel of re-exports', (t) => {
21 const source = readSource('widget-store/index.js')
22 const result = parseFile(source, 'widget-store/index.js')
1723
1824 t.absent(result.error, 'no parse error')
1925 t.is(result.exports.length, 4, '4 re-exports')
2026
2127 const names = result.exports.map(e => e.name)
22 t.ok(names.includes('HyperOrgs'), 'exports HyperOrgs')
23 t.ok(names.includes('orgsSchema'), 'exports orgsSchema')
28 t.ok(names.includes('WidgetStore'), 'exports WidgetStore')
29 t.ok(names.includes('storeSchema'), 'exports storeSchema')
2430 t.ok(names.includes('RESERVED_NAMES'), 'exports RESERVED_NAMES')
2531 t.ok(names.includes('validateName'), 'exports validateName')
2632
27 // All are re-exports from other files
28 const hyperOrgsExport = result.exports.find(e => e.name === 'HyperOrgs')
29 t.is(hyperOrgsExport.from, './lib/hyper-orgs.js', 'HyperOrgs from lib/hyper-orgs.js')
33 // Every one is a re-export, carrying the file it came from.
34 const storeExport = result.exports.find(e => e.name === 'WidgetStore')
35 t.is(storeExport.from, './lib/widget-store.js', 'WidgetStore from lib/widget-store.js')
3036
3137 const schemaExports = result.exports.filter(e => e.from === './lib/schema.js')
3238 t.is(schemaExports.length, 3, '3 exports from schema.js')
3339})
3440
35test('parser: hyper-orgs/lib/hyper-orgs.js class structure', (t) => {
36 const source = readSource('hyper-orgs/lib/hyper-orgs.js')
37 const result = parseFile(source, 'hyper-orgs/lib/hyper-orgs.js')
41test('parser: a facade class — constructor, methods, getters, state, delegation', (t) => {
42 const source = readSource('widget-store/lib/widget-store.js')
43 const result = parseFile(source, 'widget-store/lib/widget-store.js')
3844
3945 t.absent(result.error, 'no parse error')
4046
4147 // Imports
4248 t.is(result.imports.length, 2, '2 imports')
43 const roomImport = result.imports.find(i => i.specifier === 'hyper-rooms')
44 t.ok(roomImport, 'imports from hyper-rooms')
49 const roomImport = result.imports.find(i => i.specifier === 'widget-rooms')
50 t.ok(roomImport, 'imports from widget-rooms')
4551 t.ok(roomImport.names.includes('Room'), 'imports Room')
4652
4753 const schemaImport = result.imports.find(i => i.specifier === './schema.js')
……@@ -49,13 +55,13 @@ test('parser: hyper-orgs/lib/hyper-orgs.js class structure', (t) => {
4955
5056 // Exports
5157 t.is(result.exports.length, 1, '1 export')
52 t.is(result.exports[0].name, 'HyperOrgs', 'exports HyperOrgs')
58 t.is(result.exports[0].name, 'WidgetStore', 'exports WidgetStore')
5359 t.is(result.exports[0].type, 'class', 'export type is class')
5460
5561 // Class
5662 t.is(result.classes.length, 1, '1 class')
5763 const cls = result.classes[0]
58 t.is(cls.name, 'HyperOrgs', 'class name')
64 t.is(cls.name, 'WidgetStore', 'class name')
5965 t.absent(cls.extends, 'does not extend anything')
6066
6167 // Constructor
……@@ -67,73 +73,54 @@ test('parser: hyper-orgs/lib/hyper-orgs.js class structure', (t) => {
6773
6874 // Methods
6975 const methodNames = cls.methods.map(m => m.name)
70 t.ok(methodNames.includes('ready'), 'has ready()')
71 t.ok(methodNames.includes('create'), 'has create()')
72 t.ok(methodNames.includes('get'), 'has get()')
73 t.ok(methodNames.includes('update'), 'has update()')
74 t.ok(methodNames.includes('delete'), 'has delete()')
75 t.ok(methodNames.includes('list'), 'has list()')
76 t.ok(methodNames.includes('checkAccess'), 'has checkAccess()')
77 t.ok(methodNames.includes('addMember'), 'has addMember()')
78 t.ok(methodNames.includes('addResource'), 'has addResource()')
79 t.ok(methodNames.includes('createGrant'), 'has createGrant()')
80 t.ok(methodNames.includes('createInvite'), 'has createInvite()')
81 t.ok(methodNames.includes('addWriter'), 'has addWriter()')
82
83 // Async methods
76 t.is(cls.methods.length, 13, '13 methods')
77 for (const name of ['ready', 'close', 'create', 'get', 'update', 'delete', 'list',
78 'checkAccess', 'addMember', 'addResource', 'createGrant', 'createInvite', 'addWriter']) {
79 t.ok(methodNames.includes(name), 'has ' + name + '()')
80 }
81
82 // Async methods keep their signature
8483 const createMethod = cls.methods.find(m => m.name === 'create')
8584 t.ok(createMethod.async, 'create is async')
86 t.is(createMethod.params.length, 3, 'create has 3 params')
87 t.is(createMethod.params[0], 'name', 'create param 1: name')
88 t.is(createMethod.params[1], 'type', 'create param 2: type')
89 t.is(createMethod.params[2], 'data', 'create param 3: data')
85 t.alike(createMethod.params, ['name', 'type', 'data'], 'create params')
9086
9187 // Getters
92 const getterNames = cls.getters.map(g => g.name)
93 t.ok(getterNames.includes('key'), 'has key getter')
94 t.ok(getterNames.includes('keyHex'), 'has keyHex getter')
95 t.ok(getterNames.includes('writable'), 'has writable getter')
96 t.ok(getterNames.includes('view'), 'has view getter')
97 t.ok(getterNames.includes('base'), 'has base getter')
98 t.ok(getterNames.includes('room'), 'has room getter')
99
100 // State
101 t.ok(result.state.includes('_room'), 'has _room state')
102
103 // Delegation
104 t.ok(result.delegates.length > 0, 'has delegation patterns')
88 t.alike(cls.getters.map(g => g.name), ['key', 'keyHex', 'writable', 'view', 'base', 'room'],
89 'all six getters, in source order')
90
91 // State — `this._field = ...`
92 t.alike(result.state, ['_corestore', '_options', '_room', '_opened'], 'state fields')
93
94 // Delegation — `this._field.method()`
10595 const roomDelegate = result.delegates.find(d => d.field === '_room')
10696 t.ok(roomDelegate, 'delegates to _room')
10797 t.ok(roomDelegate.methods.includes('ready'), 'delegates ready to _room')
10898 t.ok(roomDelegate.methods.includes('close'), 'delegates close to _room')
10999
110 // Error contracts
111 t.ok(result.errorContracts.length > 0, 'has error contracts')
100 // Error contracts, including the template-literal form
101 t.is(result.errorContracts.length, 2, '2 error contracts')
102 t.ok(result.errorContracts.some(e => e.includes('Unknown type')), 'template error captured')
112103})
113104
114test('parser: pear-git-v3 handler factory pattern', (t) => {
115 const source = readSource('pear-git-v3/lib/handlers/orgs.js')
116 const result = parseFile(source, 'pear-git-v3/lib/handlers/orgs.js')
105test('parser: the handler-factory pattern', (t) => {
106 const source = readSource('widget-store/lib/handlers/widgets.js')
107 const result = parseFile(source, 'widget-store/lib/handlers/widgets.js')
117108
118109 t.absent(result.error, 'no parse error')
119
120 // Should have an exported function (the handler factory)
121110 const exportedFns = result.exports.filter(e => e.type === 'function')
122 t.ok(exportedFns.length > 0, 'has exported function(s)')
111 t.is(exportedFns.length, 1, '1 exported factory')
112 t.is(exportedFns[0].name, 'createWidgetHandlers', 'named createWidgetHandlers')
123113})
124114
125test('parser: CJS module (hyper-sync)', (t) => {
126 const source = readSource('hyper-sync/index.js')
127 const result = parseFile(source, 'hyper-sync/index.js')
128
129 t.absent(result.error, 'no parse error')
130
131 // Should detect CJS exports
132 const hasExport = result.exports.length > 0
133 t.ok(hasExport, 'detected CJS exports')
115test('parser: a CJS module', (t) => {
116 const source = readSource('legacy-sync/index.js')
117 const result = parseFile(source, 'legacy-sync/index.js')
134118
135 // Should detect a class
136 t.ok(result.classes.length > 0, 'found class(es)')
119 t.absent(result.error, 'no parse error — script mode fallback handled it')
120 t.ok(result.exports.length > 0, 'detected CJS exports')
121 t.ok(result.exports[0].cjs, 'marked as CJS')
122 t.is(result.classes.length, 1, 'found the class')
123 t.is(result.classes[0].name, 'Syncer', 'class is Syncer')
137124})
138125
139126test('parser: JSDoc comment attachment', (t) => {
test/traverse.test.js +47 -54
……@@ -4,94 +4,87 @@ import { fileURLToPath } from 'url'
44import { walkGraph } from '../lib/traverse.js'
55
66const HERE = dirname(fileURLToPath(import.meta.url))
7const HYPER_FLEEKY = resolve(HERE, '../../')
87
9test('traverse: hyper-orgs module', async (t) => {
10 const graph = await walkGraph(resolve(HYPER_FLEEKY, 'hyper-orgs'))
8// Was `resolve(HERE, '../../')` — the live hyper-fleeky tree. A sibling repo's
9// version bump turned two assertions here red (invariant I1). fixtures/workspace
10// is ours and cannot drift; see its README for what stands in for what.
11const FIXTURE = resolve(HERE, 'fixtures/workspace')
12
13test('traverse: a single module', async (t) => {
14 const graph = await walkGraph(resolve(FIXTURE, 'widget-store'))
1115
1216 t.is(graph.modules.size, 1, '1 module found')
13 t.ok(graph.modules.has('hyper-orgs'), 'found hyper-orgs')
17 t.ok(graph.modules.has('widget-store'), 'found widget-store')
1418
15 const mod = graph.modules.get('hyper-orgs')
19 const mod = graph.modules.get('widget-store')
1620 t.is(mod.version, '0.2.0', 'correct version')
1721 t.ok(mod.entry.endsWith('index.js'), 'entry is index.js')
18 t.ok(mod.dependencies['hyper-rooms'], 'depends on hyper-rooms')
22 t.ok(mod.dependencies['widget-rooms'], 'depends on widget-rooms')
1923 t.is(mod.testCommand, 'brittle test/*.test.js', 'test command')
2024 t.is(mod.type, 'module', 'ESM module')
2125
2226 // Files
2327 t.ok(mod.files.has('index.js'), 'has index.js')
24 t.ok(mod.files.has('lib/hyper-orgs.js'), 'has lib/hyper-orgs.js')
28 t.ok(mod.files.has('lib/widget-store.js'), 'has lib/widget-store.js')
2529 t.ok(mod.files.has('lib/schema.js'), 'has lib/schema.js')
30 t.ok(mod.files.has('lib/handlers/widgets.js'), 'walks nested directories')
31 t.ok(mod.files.has('test/store.test.js'), 'includes the test file')
2632
2733 // File is parsed (source no longer retained — see TreeReducer.onFileDiscovered)
2834 const indexFile = mod.files.get('index.js')
2935 t.ok(indexFile.parsed, 'index.js has parsed result')
30 t.ok(indexFile.parsed.exports.length > 0, 'index.js has exports')
36 t.is(indexFile.parsed.exports.length, 4, 'index.js has its 4 exports')
3137})
3238
33test('traverse: hyper-orgs file relationships', async (t) => {
34 const graph = await walkGraph(resolve(HYPER_FLEEKY, 'hyper-orgs'))
35 const mod = graph.modules.get('hyper-orgs')
39test('traverse: file relationships', async (t) => {
40 const graph = await walkGraph(resolve(FIXTURE, 'widget-store'))
41 const mod = graph.modules.get('widget-store')
3642
37 // index.js imports from lib/
43 // The barrel imports from lib/
3844 const indexFile = mod.files.get('index.js')
39 t.ok(indexFile.localImports.length > 0, 'index.js has local imports')
45 t.alike(indexFile.localImports.map(i => i.specifier).sort(),
46 ['./lib/schema.js', './lib/widget-store.js'], 'barrel resolves both lib imports')
4047
41 const importPaths = indexFile.localImports.map(i => i.specifier)
42 t.ok(importPaths.includes('./lib/hyper-orgs.js'), 'imports from lib/hyper-orgs.js')
43 t.ok(importPaths.includes('./lib/schema.js'), 'imports from lib/schema.js')
48 // lib/widget-store.js imports schema.js — a relative edge one level down
49 const mainFile = mod.files.get('lib/widget-store.js')
50 t.alike(mainFile.localImports.map(i => i.specifier), ['./schema.js'],
51 'widget-store.js imports schema.js')
4452
45 // lib/hyper-orgs.js imports from schema.js
46 const mainFile = mod.files.get('lib/hyper-orgs.js')
47 const mainImports = mainFile.localImports.map(i => i.specifier)
48 t.ok(mainImports.includes('./schema.js'), 'hyper-orgs.js imports schema.js')
53 // 'widget-rooms' is a package, not a local edge, so it is NOT a localImport
54 t.absent(mainFile.localImports.find(i => i.specifier === 'widget-rooms'),
55 'a bare specifier is not a local import')
4956})
5057
51test('traverse: hyper-deploy import maps', async (t) => {
52 const graph = await walkGraph(resolve(HYPER_FLEEKY, 'hyper-deploy'))
53
54 t.ok(graph.modules.size >= 1, 'found module(s)')
55
56 const mod = graph.modules.get('hyper-deploy')
57 if (!mod) {
58 // Might be named differently
59 const first = graph.modules.values().next().value
60 t.ok(first.importMaps, 'module has importMaps field')
61 return
62 }
58test('traverse: import maps', async (t) => {
59 const graph = await walkGraph(resolve(FIXTURE, 'mapped-io'))
6360
61 const mod = graph.modules.get('mapped-io')
62 t.ok(mod, 'found mapped-io')
6463 t.ok(mod.importMaps, 'has import maps')
65 // hyper-deploy uses #fs, #path, #os import maps
66 const hasImportMap = Object.keys(mod.importMaps).length > 0
67 t.ok(hasImportMap, 'import maps are populated')
64 t.alike(Object.keys(mod.importMaps).sort(), ['fs', 'os', 'path'], 'all three maps')
65 t.is(mod.importMaps.fs.bare, 'bare-fs', 'fs maps to bare-fs under Bare')
66 t.is(mod.importMaps.fs.default, 'fs', 'and to fs by default')
6867})
6968
7069test('traverse: multi-module scan', async (t) => {
71 // Scan a directory containing multiple modules
72 const graph = await walkGraph(HYPER_FLEEKY, { maxDepth: 1 })
70 const graph = await walkGraph(FIXTURE, { maxDepth: 1 })
7371
74 // Should find multiple modules
75 t.ok(graph.modules.size > 5, `found ${graph.modules.size} modules`)
72 // Deterministic now: the old version could only assert "more than five",
73 // because it was counting whatever happened to be checked out.
74 t.is(graph.modules.size, 4, '4 modules')
75 t.alike(Array.from(graph.modules.keys()).sort(),
76 ['legacy-sync', 'mapped-io', 'widget-rooms', 'widget-store'], 'the expected set')
7677
77 // Check some known modules exist
78 const names = Array.from(graph.modules.keys())
79 t.comment(`found modules: ${names.slice(0, 10).join(', ')}...`)
80
81 // Each module should have files
8278 for (const [name, mod] of graph.modules) {
83 t.ok(mod.files.size > 0, `${name} has ${mod.files.size} files`)
79 t.ok(mod.files.size > 0, name + ' has ' + mod.files.size + ' files')
8480 }
8581})
8682
87test('traverse: CJS module (hyper-sync)', async (t) => {
88 const graph = await walkGraph(resolve(HYPER_FLEEKY, 'hyper-sync'))
89
90 t.ok(graph.modules.size >= 1, 'found module')
83test('traverse: a CJS module', async (t) => {
84 const graph = await walkGraph(resolve(FIXTURE, 'legacy-sync'))
9185
92 const mod = graph.modules.get('hyper-sync')
93 if (mod) {
94 t.is(mod.type, 'commonjs', 'detected as commonjs (no type field)')
95 t.ok(mod.files.has('index.js'), 'has index.js')
96 }
86 const mod = graph.modules.get('legacy-sync')
87 t.ok(mod, 'found legacy-sync')
88 t.is(mod.type, 'commonjs', 'detected as commonjs (no type field)')
89 t.ok(mod.files.has('index.js'), 'has index.js')
9790})
test/tree.test.js +52 -72
……@@ -4,123 +4,103 @@ import { fileURLToPath } from 'url'
44import { scan } from '../lib/scanner.js'
55
66const HERE = dirname(fileURLToPath(import.meta.url))
7const HYPER_FLEEKY = resolve(HERE, '../../')
87
9test('tree: single module (hyper-orgs)', async (t) => {
10 const tree = await scan(resolve(HYPER_FLEEKY, 'hyper-orgs'))
8// Was `resolve(HERE, '../../')` — the live hyper-fleeky tree, which put five
9// assertions here at the mercy of a sibling repo's version bump (invariant I1).
10const FIXTURE = resolve(HERE, 'fixtures/workspace')
11
12test('tree: single module', async (t) => {
13 const tree = await scan(resolve(FIXTURE, 'widget-store'))
1114
1215 t.ok(tree.generated, 'has timestamp')
1316 t.ok(tree.root, 'has root path')
1417
1518 // System level
16 t.ok(tree.system.modules.includes('hyper-orgs'), 'module listed')
17 t.ok(tree.system.dependencies['hyper-orgs'], 'has dependencies')
19 t.ok(tree.system.modules.includes('widget-store'), 'module listed')
20 t.ok(tree.system.dependencies['widget-store'], 'has dependencies')
1821
1922 // Module level
20 const mod = tree.modules['hyper-orgs']
23 const mod = tree.modules['widget-store']
2124 t.ok(mod, 'module entry exists')
22 t.is(mod.name, 'hyper-orgs', 'correct name')
25 t.is(mod.name, 'widget-store', 'correct name')
2326 t.is(mod.version, '0.2.0', 'correct version')
2427 t.is(mod.type, 'module', 'ESM')
2528 t.is(mod.entry, 'index.js', 'entry point')
26 t.ok(mod.testCommand, 'has test command')
29 t.is(mod.testCommand, 'brittle test/*.test.js', 'has test command')
2730
28 // Public API
29 t.ok(mod.publicAPI.includes('HyperOrgs'), 'public API has HyperOrgs')
30 t.ok(mod.publicAPI.includes('orgsSchema'), 'public API has orgsSchema')
31 t.ok(mod.publicAPI.includes('validateName'), 'public API has validateName')
32 t.ok(mod.publicAPI.includes('RESERVED_NAMES'), 'public API has RESERVED_NAMES')
31 // Public API comes from the entry file's exports
32 t.alike(mod.publicAPI, ['WidgetStore', 'storeSchema', 'RESERVED_NAMES', 'validateName'],
33 'public API is the barrel, in source order')
3334
3435 // Files
3536 t.ok(mod.files['index.js'], 'has index.js')
36 t.is(mod.files['index.js'].role, 'entry', 'index.js is entry')
37 t.ok(mod.files['lib/hyper-orgs.js'], 'has lib/hyper-orgs.js')
38 t.is(mod.files['lib/hyper-orgs.js'].role, 'lib', 'hyper-orgs.js is lib')
37 t.ok(mod.files['lib/widget-store.js'], 'has lib/widget-store.js')
3938 t.ok(mod.files['lib/schema.js'], 'has lib/schema.js')
40 t.is(mod.files['lib/schema.js'].role, 'schema', 'schema.js is schema')
4139})
4240
43test('tree: file-level detail (hyper-orgs/lib/hyper-orgs.js)', async (t) => {
44 const tree = await scan(resolve(HYPER_FLEEKY, 'hyper-orgs'))
45 const file = tree.modules['hyper-orgs'].files['lib/hyper-orgs.js']
41test('tree: file-level detail', async (t) => {
42 const tree = await scan(resolve(FIXTURE, 'widget-store'))
43 const file = tree.modules['widget-store'].files['lib/widget-store.js']
4644
4745 // Imports
48 t.ok(file.imports.length > 0, 'has imports')
49 const roomImport = file.imports.find(i => i.specifier === 'hyper-rooms')
50 t.ok(roomImport, 'imports hyper-rooms')
46 t.is(file.imports.length, 2, '2 imports')
47 const roomImport = file.imports.find(i => i.specifier === 'widget-rooms')
48 t.ok(roomImport, 'imports widget-rooms')
5149
5250 // Exports
5351 t.is(file.exports.length, 1, '1 export')
54 t.is(file.exports[0].name, 'HyperOrgs', 'exports HyperOrgs')
52 t.is(file.exports[0].name, 'WidgetStore', 'exports WidgetStore')
5553 t.is(file.exports[0].type, 'class', 'type is class')
5654
5755 // Class structure
58 t.ok(file.classes, 'has classes')
5956 t.is(file.classes.length, 1, '1 class')
60
6157 const cls = file.classes[0]
62 t.is(cls.name, 'HyperOrgs', 'class name')
58 t.is(cls.name, 'WidgetStore', 'class name')
6359 t.ok(cls.constructor, 'has constructor')
64 t.ok(cls.methods.length > 10, `has ${cls.methods.length} methods`)
65 t.ok(cls.getters.length > 3, `has ${cls.getters.length} getters`)
60 t.is(cls.methods.length, 13, '13 methods')
61 t.is(cls.getters.length, 6, '6 getters')
6662
67 // State and delegation
68 t.ok(file.state, 'has state')
63 // State and delegation survive into the tree, not just the parser
6964 t.ok(file.state.includes('_room'), 'state includes _room')
70 t.ok(file.delegates, 'has delegates')
71 t.ok(file.delegates.length > 0, 'has delegation patterns')
65 t.ok(file.delegates.find(d => d.field === '_room'), 'delegates to _room')
7266
7367 // Error contracts
74 t.ok(file.errorContracts, 'has error contracts')
75 t.ok(file.errorContracts.length > 0, 'has specific errors')
68 t.is(file.errorContracts.length, 2, '2 error contracts')
7669})
7770
7871test('tree: multi-module scan with shared deps', async (t) => {
79 const tree = await scan(HYPER_FLEEKY, { maxDepth: 1 })
72 const tree = await scan(FIXTURE, { maxDepth: 1 })
8073
81 // System level
82 t.ok(tree.system.modules.length > 10, `${tree.system.modules.length} modules`)
83
84 // Shared deps
85 t.ok(tree.system.sharedDeps, 'has shared deps')
86 const sharedNames = Object.keys(tree.system.sharedDeps)
87 t.ok(sharedNames.length > 0, `${sharedNames.length} shared deps`)
88 t.comment(`shared deps: ${sharedNames.slice(0, 5).join(', ')}...`)
89
90 // Verify some modules have parsed file data
91 const orgs = tree.modules['hyper-orgs']
92 if (orgs) {
93 t.ok(orgs.files['lib/hyper-orgs.js'].classes, 'hyper-orgs has parsed class data')
94 }
95
96 const issues = tree.modules['hyper-issues']
97 if (issues) {
98 t.ok(issues.publicAPI.length > 0, `hyper-issues has public API: ${issues.publicAPI.join(', ')}`)
99 }
74 t.is(tree.system.modules.length, 4, '4 modules')
75
76 // A shared dep is one used by 2+ modules — here b4a, by both ESM packages.
77 t.alike(tree.system.sharedDeps, { b4a: ['widget-rooms', 'widget-store'] },
78 'b4a is the shared dep, with both users named')
79
80 // Parsed file data survives a multi-module scan (it once did not)
81 t.ok(tree.modules['widget-store'].files['lib/widget-store.js'].classes,
82 'widget-store has parsed class data')
83 t.ok(tree.modules['widget-rooms'].publicAPI.includes('Room'),
84 'widget-rooms exposes Room')
10085})
10186
10287test('tree: dependency graph is populated', async (t) => {
103 const tree = await scan(HYPER_FLEEKY, { maxDepth: 1 })
104
105 const depsEntries = Object.entries(tree.system.dependencies)
106 t.ok(depsEntries.length > 0, `${depsEntries.length} modules have deps`)
88 const tree = await scan(FIXTURE, { maxDepth: 1 })
10789
108 // hyper-orgs depends on hyper-rooms
109 const orgDeps = tree.system.dependencies['hyper-orgs']
110 if (orgDeps) {
111 t.ok(orgDeps.includes('hyper-rooms'), 'hyper-orgs -> hyper-rooms')
112 }
90 // No `if (deps)` guard any more: the fixture cannot stop depending on this.
91 t.alike(tree.system.dependencies['widget-store'], ['widget-rooms', 'b4a'],
92 'widget-store -> widget-rooms, b4a')
93 t.alike(tree.system.dependencies['widget-rooms'], ['b4a'],
94 'widget-rooms -> b4a')
11395})
11496
11597test('tree: file roles are correctly classified', async (t) => {
116 const tree = await scan(resolve(HYPER_FLEEKY, 'hyper-orgs'))
117 const files = tree.modules['hyper-orgs'].files
98 const tree = await scan(resolve(FIXTURE, 'widget-store'))
99 const files = tree.modules['widget-store'].files
118100
119101 t.is(files['index.js'].role, 'entry', 'index.js = entry')
120 t.is(files['lib/hyper-orgs.js'].role, 'lib', 'lib/*.js = lib')
121 t.is(files['lib/schema.js'].role, 'schema', 'schema.js = schema')
122
123 // Check for test files if they exist
124 const testFiles = Object.entries(files).filter(([_, f]) => f.role === 'test')
125 t.comment(`test files: ${testFiles.length}`)
102 t.is(files['lib/widget-store.js'].role, 'lib', 'lib/*.js = lib')
103 t.is(files['lib/schema.js'].role, 'schema', 'anything named schema = schema')
104 t.is(files['lib/handlers/widgets.js'].role, 'lib', 'nested under lib/ = lib')
105 t.is(files['test/store.test.js'].role, 'test', 'test/*.test.js = test')
126106})