spexcode 0.6.0 → 0.6.2
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.
- package/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import { git, gitA, gitTry, headSha } from '../../spec-cli/src/git.js'
|
|
2
|
+
import { batchBlobTexts, batchRevisionOids, git, gitA, gitTry, headSha } from '../../spec-cli/src/git.js'
|
|
3
3
|
import { parseScenarios } from './scenarios.js'
|
|
4
4
|
import { rootSlots, touchRoot as touchRootLru } from '../../spec-cli/src/root-lru.js'
|
|
5
5
|
|
|
@@ -159,25 +159,75 @@ function oidAt(root: string, rev: string, path: string): string {
|
|
|
159
159
|
return v
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// @@@ the memo holds SETTLED oids, so concurrent askers need a flight to join - primes run concurrently
|
|
163
|
+
// across the whole scope, and every one of them missed the memo while the first child was still running:
|
|
164
|
+
// measured on adopter-a's 415-node scope, 2695 `rev-parse` children resolved 1212 distinct (rev, path) pairs.
|
|
165
|
+
// `rev:path` under a full sha names an immutable object, so joining answers the same question; the LRU
|
|
166
|
+
// write still happens once, on settle, exactly as the sync path does it.
|
|
167
|
+
const oidFlight = new Map<string, Promise<string>>()
|
|
162
168
|
async function oidAtAsync(root: string, rev: string, path: string): Promise<string> {
|
|
163
169
|
if (!FULL_SHA.test(rev)) return (await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])).stdout.trim()
|
|
164
170
|
const k = `${root}\x1f${rev}\x1f${path}`
|
|
165
171
|
const hit = oidMemo.get(k)
|
|
166
172
|
if (hit !== undefined) { oidMemo.delete(k); oidMemo.set(k, hit); return hit }
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
173
|
+
const joined = oidFlight.get(k)
|
|
174
|
+
if (joined) return joined
|
|
175
|
+
const run = async (): Promise<string> => {
|
|
176
|
+
const result = await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])
|
|
177
|
+
const oid = result.ok ? result.stdout.trim() : ''
|
|
178
|
+
oidMemo.set(k, oid)
|
|
179
|
+
if (oidMemo.size > 4096) oidMemo.delete(oidMemo.keys().next().value!)
|
|
180
|
+
return oid
|
|
181
|
+
}
|
|
182
|
+
const flight = run().finally(() => { if (oidFlight.get(k) === flight) oidFlight.delete(k) })
|
|
183
|
+
oidFlight.set(k, flight)
|
|
184
|
+
return flight
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
|
|
188
|
+
// @@@ the PLURAL prime, mirroring the anchor probe's - a caller hands over every (rev, path) its whole read
|
|
189
|
+
// will ask about and TWO children answer them all: `cat-file --batch-check` for the object ids, then
|
|
190
|
+
// `cat-file --batch` for the blobs. The singular form below is one demand's worth of the same thing, kept
|
|
191
|
+
// for callers that genuinely learn their demand one at a time. Priming one at a time is what this replaces:
|
|
192
|
+
// on adopter-a's 415-node scope it forked 1212 `rev-parse` children plus a `cat-file` each, and the batch
|
|
193
|
+
// answers the identical set — same oids, same blocks, same verdicts, two children.
|
|
194
|
+
export async function primeScenarioBlocks(root: string, demands: readonly { rev: string; path: string }[]): Promise<void> {
|
|
195
|
+
const wanted = new Map<string, { rev: string; path: string }>()
|
|
196
|
+
for (const d of demands) {
|
|
197
|
+
if (!FULL_SHA.test(d.rev) || !d.path) continue // a symbolic rev is never memoized, so it can't ride a batch
|
|
198
|
+
const k = `${root}\x1f${d.rev}\x1f${d.path}`
|
|
199
|
+
if (!oidMemo.has(k) && !wanted.has(k)) wanted.set(k, d)
|
|
200
|
+
}
|
|
201
|
+
if (!wanted.size) return
|
|
202
|
+
const keys = [...wanted.keys()]
|
|
203
|
+
const rows = [...wanted.values()]
|
|
204
|
+
const oids = await batchRevisionOids(root, rows.map((d) => `${d.rev}:${d.path}`))
|
|
205
|
+
oids.forEach((oid, i) => {
|
|
206
|
+
oidMemo.set(keys[i], oid ?? '')
|
|
207
|
+
if (oidMemo.size > 4096) oidMemo.delete(oidMemo.keys().next().value!)
|
|
208
|
+
})
|
|
209
|
+
const missing = [...new Set(oids.filter((o): o is string => !!o && !blockByOid.has(o)))]
|
|
210
|
+
if (!missing.length) return
|
|
211
|
+
const texts = await batchBlobTexts(root, missing)
|
|
212
|
+
for (const [oid, src] of texts) if (src) blockByOid.set(oid, blockContent(src))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// the blob read needs the same flight as the oid lookup above it: an oid is content, so two primes that
|
|
216
|
+
// resolved the same one must not each read it.
|
|
217
|
+
const blockFlight = new Map<string, Promise<void>>()
|
|
175
218
|
export async function primeScenarioBlocksAt(root: string, revs: string[], path: string): Promise<void> {
|
|
176
219
|
for (const rev of revs) {
|
|
177
220
|
const oid = await oidAtAsync(root, rev, path)
|
|
178
221
|
if (!oid || blockByOid.has(oid)) continue
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
222
|
+
const joined = blockFlight.get(oid)
|
|
223
|
+
if (joined) { await joined; continue }
|
|
224
|
+
const run = async (): Promise<void> => {
|
|
225
|
+
const src = await gitA(['-C', root, 'cat-file', 'blob', oid]) // dead-words-ok: git plumbing
|
|
226
|
+
if (src) blockByOid.set(oid, blockContent(src))
|
|
227
|
+
}
|
|
228
|
+
const flight = run().finally(() => { if (blockFlight.get(oid) === flight) blockFlight.delete(oid) })
|
|
229
|
+
blockFlight.set(oid, flight)
|
|
230
|
+
await flight
|
|
181
231
|
}
|
|
182
232
|
}
|
|
183
233
|
|
|
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
|
2
2
|
import { readFile, readdir } from 'node:fs/promises'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { join, relative, basename } from 'node:path'
|
|
5
|
-
import { mintIds } from '../../spec-cli/src/specs.js'
|
|
5
|
+
import { mintIds, parseFrontmatter } from '../../spec-cli/src/specs.js'
|
|
6
6
|
import { parseRelation, type RelationEntry } from '../../spec-cli/src/anchors.js'
|
|
7
7
|
import { treeTextFiles } from '../../spec-cli/src/git.js'
|
|
8
8
|
|
|
@@ -41,13 +41,20 @@ export type ScenarioProjectionRow = {
|
|
|
41
41
|
semantic: ScenarioSemanticRow
|
|
42
42
|
measurement: ScenarioMeasurementRow
|
|
43
43
|
}
|
|
44
|
+
export type ScenarioProjectionNode = {
|
|
45
|
+
id: string
|
|
46
|
+
code: RelationEntry[]
|
|
47
|
+
related: RelationEntry[]
|
|
48
|
+
}
|
|
44
49
|
export type ScenarioProjectionProvenance = { head: string | null; treeSha: string | null }
|
|
45
50
|
export type ScenarioProjection = {
|
|
46
51
|
projection: typeof SCENARIO_PROJECTION
|
|
47
52
|
schemaVersion: typeof SCENARIO_SCHEMA_VERSION
|
|
48
53
|
provenance: ScenarioProjectionProvenance
|
|
54
|
+
nodes: ScenarioProjectionNode[]
|
|
49
55
|
semanticIndexHash: string
|
|
50
56
|
fullIndexHash: string
|
|
57
|
+
planningIndexHash: string
|
|
51
58
|
rows: ScenarioProjectionRow[]
|
|
52
59
|
}
|
|
53
60
|
|
|
@@ -58,6 +65,7 @@ export type EvalNode = {
|
|
|
58
65
|
sidecarPath: string
|
|
59
66
|
scenarios: Scenario[]
|
|
60
67
|
evalSource?: string
|
|
68
|
+
specSource?: string
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
const SCENARIO_KEYS = ['name', 'description', 'expected', 'tags', 'test', 'code', 'related'] as const
|
|
@@ -349,11 +357,29 @@ const hashProjection = (value: unknown): string =>
|
|
|
349
357
|
createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex')
|
|
350
358
|
|
|
351
359
|
const semanticOnly = (row: ScenarioProjectionRow): ScenarioSemanticRow => row.semantic
|
|
360
|
+
const frontmatterList = (value: string | string[] | undefined): string[] =>
|
|
361
|
+
Array.isArray(value) ? value : value ? [value] : []
|
|
362
|
+
|
|
363
|
+
function projectionNode(node: Pick<EvalNode, 'id' | 'specSource'>): ScenarioProjectionNode {
|
|
364
|
+
const fm = node.specSource ? parseFrontmatter(node.specSource).fm : {}
|
|
365
|
+
const code = parseRelation(frontmatterList(fm.code), 'code')
|
|
366
|
+
const related = parseRelation(frontmatterList(fm.related), 'related')
|
|
367
|
+
const problems = [...code.problems, ...related.problems]
|
|
368
|
+
if (problems.length) {
|
|
369
|
+
throw new Error(`node '${node.id}' has malformed spec relations:\n${problems.map((e) => ` - ${e}`).join('\n')}`)
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
id: node.id,
|
|
373
|
+
code: code.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
|
|
374
|
+
related: related.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
|
|
375
|
+
}
|
|
376
|
+
}
|
|
352
377
|
|
|
353
378
|
export function scenarioProjection(
|
|
354
|
-
nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource'>[],
|
|
379
|
+
nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource' | 'specSource'>[],
|
|
355
380
|
provenance: Partial<ScenarioProjectionProvenance> = {},
|
|
356
381
|
): ScenarioProjection {
|
|
382
|
+
const nodeRows = nodes.map(projectionNode).sort((a, b) => compareStable(a.id, b.id))
|
|
357
383
|
const rows: ScenarioProjectionRow[] = []
|
|
358
384
|
for (const node of nodes) {
|
|
359
385
|
if ('evalSource' in node && node.evalSource !== undefined) {
|
|
@@ -388,8 +414,10 @@ export function scenarioProjection(
|
|
|
388
414
|
projection: SCENARIO_PROJECTION,
|
|
389
415
|
schemaVersion: SCENARIO_SCHEMA_VERSION,
|
|
390
416
|
provenance: { head: provenance.head ?? null, treeSha: provenance.treeSha ?? null },
|
|
417
|
+
nodes: nodeRows,
|
|
391
418
|
semanticIndexHash: hashProjection(semanticRows),
|
|
392
419
|
fullIndexHash: hashProjection(rows),
|
|
420
|
+
planningIndexHash: hashProjection({ nodes: nodeRows, rows }),
|
|
393
421
|
rows,
|
|
394
422
|
}
|
|
395
423
|
}
|
|
@@ -543,18 +571,19 @@ export function writeScenarioMeasurementMetadata(source: string, request: unknow
|
|
|
543
571
|
return proposed
|
|
544
572
|
}
|
|
545
573
|
|
|
546
|
-
function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): EvalNode[] {
|
|
574
|
+
function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string; specSource?: string }[]): EvalNode[] {
|
|
547
575
|
const specBase = join(root, '.spec')
|
|
548
576
|
const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)))
|
|
549
577
|
const idByDir = new Map(specDirs.map((d, i) => [d, ids[i]]))
|
|
550
578
|
return hits
|
|
551
|
-
.map(({ dir, src }) => ({
|
|
579
|
+
.map(({ dir, src, specSource }) => ({
|
|
552
580
|
id: idByDir.get(dir) ?? basename(dir),
|
|
553
581
|
dir,
|
|
554
582
|
evalPath: relative(root, join(dir, EVAL_FILE)),
|
|
555
583
|
sidecarPath: join(dir, SIDECAR_FILE),
|
|
556
584
|
scenarios: parseScenarios(src),
|
|
557
585
|
evalSource: src,
|
|
586
|
+
...(specSource !== undefined ? { specSource } : {}),
|
|
558
587
|
}))
|
|
559
588
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
560
589
|
}
|
|
@@ -562,14 +591,21 @@ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; sr
|
|
|
562
591
|
export function evalNodes(root: string): EvalNode[] {
|
|
563
592
|
const specDir = join(root, '.spec')
|
|
564
593
|
const specDirs: string[] = []
|
|
565
|
-
const hits: { dir: string; src: string }[] = []
|
|
594
|
+
const hits: { dir: string; src: string; specSource?: string }[] = []
|
|
566
595
|
const stack = existsSync(specDir) ? [specDir] : []
|
|
567
596
|
while (stack.length) {
|
|
568
597
|
const dir = stack.pop()!
|
|
569
598
|
let ents
|
|
570
599
|
try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
|
|
571
600
|
if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
|
|
572
|
-
if (existsSync(join(dir, EVAL_FILE)))
|
|
601
|
+
if (existsSync(join(dir, EVAL_FILE))) {
|
|
602
|
+
const specPath = join(dir, 'spec.md')
|
|
603
|
+
hits.push({
|
|
604
|
+
dir,
|
|
605
|
+
src: readFileSync(join(dir, EVAL_FILE), 'utf8'),
|
|
606
|
+
...(existsSync(specPath) ? { specSource: readFileSync(specPath, 'utf8') } : {}),
|
|
607
|
+
})
|
|
608
|
+
}
|
|
573
609
|
for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
|
|
574
610
|
}
|
|
575
611
|
return assembleNodes(root, specDirs, hits)
|
|
@@ -583,10 +619,15 @@ export function evalNodesAt(root: string, tip: string): EvalNode[] {
|
|
|
583
619
|
.map((path) => join(root, path.slice(0, -'/spec.md'.length)))
|
|
584
620
|
const hits = paths
|
|
585
621
|
.filter((path) => path.endsWith(`/${EVAL_FILE}`))
|
|
586
|
-
.map((path) =>
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
622
|
+
.map((path) => {
|
|
623
|
+
const relDir = path.slice(0, -`/${EVAL_FILE}`.length)
|
|
624
|
+
const specSource = files.get(`${relDir}/spec.md`)
|
|
625
|
+
return {
|
|
626
|
+
dir: join(root, relDir),
|
|
627
|
+
src: files.get(path)!,
|
|
628
|
+
...(specSource !== undefined ? { specSource } : {}),
|
|
629
|
+
}
|
|
630
|
+
})
|
|
590
631
|
return assembleNodes(root, specDirs, hits)
|
|
591
632
|
}
|
|
592
633
|
|
|
@@ -597,14 +638,21 @@ export function evalNodesAt(root: string, tip: string): EvalNode[] {
|
|
|
597
638
|
export async function evalNodesAsync(root: string): Promise<EvalNode[]> {
|
|
598
639
|
const specDir = join(root, '.spec')
|
|
599
640
|
const specDirs: string[] = []
|
|
600
|
-
const hits: { dir: string; src: string }[] = []
|
|
641
|
+
const hits: { dir: string; src: string; specSource?: string }[] = []
|
|
601
642
|
const stack = existsSync(specDir) ? [specDir] : []
|
|
602
643
|
while (stack.length) {
|
|
603
644
|
const dir = stack.pop()!
|
|
604
645
|
let ents
|
|
605
646
|
try { ents = await readdir(dir, { withFileTypes: true }) } catch { continue }
|
|
606
647
|
if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
|
|
607
|
-
if (existsSync(join(dir, EVAL_FILE)))
|
|
648
|
+
if (existsSync(join(dir, EVAL_FILE))) {
|
|
649
|
+
const specPath = join(dir, 'spec.md')
|
|
650
|
+
hits.push({
|
|
651
|
+
dir,
|
|
652
|
+
src: await readFile(join(dir, EVAL_FILE), 'utf8'),
|
|
653
|
+
...(existsSync(specPath) ? { specSource: await readFile(specPath, 'utf8') } : {}),
|
|
654
|
+
})
|
|
655
|
+
}
|
|
608
656
|
for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
|
|
609
657
|
}
|
|
610
658
|
return assembleNodes(root, specDirs, hits)
|