@grafema/util 0.3.18 → 0.3.20

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.
Files changed (45) hide show
  1. package/dist/federation/FederatedRouter.d.ts +124 -0
  2. package/dist/federation/FederatedRouter.d.ts.map +1 -0
  3. package/dist/federation/FederatedRouter.js +297 -0
  4. package/dist/federation/FederatedRouter.js.map +1 -0
  5. package/dist/federation/ShardDiscovery.d.ts +56 -0
  6. package/dist/federation/ShardDiscovery.d.ts.map +1 -0
  7. package/dist/federation/ShardDiscovery.js +100 -0
  8. package/dist/federation/ShardDiscovery.js.map +1 -0
  9. package/dist/federation/index.d.ts +28 -0
  10. package/dist/federation/index.d.ts.map +1 -0
  11. package/dist/federation/index.js +26 -0
  12. package/dist/federation/index.js.map +1 -0
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/manifest/generator.d.ts.map +1 -1
  18. package/dist/manifest/generator.js +26 -4
  19. package/dist/manifest/generator.js.map +1 -1
  20. package/dist/manifest/index.d.ts +2 -0
  21. package/dist/manifest/index.d.ts.map +1 -1
  22. package/dist/manifest/index.js +1 -0
  23. package/dist/manifest/index.js.map +1 -1
  24. package/dist/manifest/registry.d.ts +116 -0
  25. package/dist/manifest/registry.d.ts.map +1 -0
  26. package/dist/manifest/registry.js +638 -0
  27. package/dist/manifest/registry.js.map +1 -0
  28. package/dist/manifest/resolver.d.ts +9 -0
  29. package/dist/manifest/resolver.d.ts.map +1 -1
  30. package/dist/manifest/resolver.js +31 -0
  31. package/dist/manifest/resolver.js.map +1 -1
  32. package/dist/notation/traceRenderer.d.ts +2 -0
  33. package/dist/notation/traceRenderer.d.ts.map +1 -1
  34. package/dist/notation/traceRenderer.js +6 -5
  35. package/dist/notation/traceRenderer.js.map +1 -1
  36. package/package.json +3 -3
  37. package/src/federation/FederatedRouter.ts +440 -0
  38. package/src/federation/ShardDiscovery.ts +130 -0
  39. package/src/federation/index.ts +35 -0
  40. package/src/index.ts +16 -1
  41. package/src/manifest/generator.ts +25 -4
  42. package/src/manifest/index.ts +2 -0
  43. package/src/manifest/registry.ts +769 -0
  44. package/src/manifest/resolver.ts +33 -0
  45. package/src/notation/traceRenderer.ts +8 -5
@@ -0,0 +1,769 @@
1
+ /**
2
+ * RegistryBuilder — builds a local manifest registry from npm packages.
3
+ *
4
+ * Resolves installed npm packages, analyzes each via grafema-orchestrator,
5
+ * generates manifest.yaml with ManifestGenerator, and writes to a registry/
6
+ * directory with an index.yaml catalog.
7
+ *
8
+ * Usage:
9
+ * const builder = new RegistryBuilder({ projectPath: '.', registryDir: './registry' });
10
+ * await builder.buildPackage('commander');
11
+ * await builder.buildAll();
12
+ * await builder.writeIndex();
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, rmSync, cpSync } from 'fs';
16
+ import { join, resolve, dirname } from 'path';
17
+ import { createRequire } from 'module';
18
+ import { spawn } from 'child_process';
19
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
20
+ import { tmpdir } from 'os';
21
+
22
+ import { GRAFEMA_VERSION } from '../version.js';
23
+ import { findOrchestratorBinary } from '../utils/findRfdbBinary.js';
24
+ import { ensureBinary } from '../utils/lazyDownload.js';
25
+ import { RFDBServerBackend } from '../storage/backends/RFDBServerBackend.js';
26
+ import { ManifestGenerator } from './generator.js';
27
+ import type { Manifest, ManifestPackage } from './types.js';
28
+
29
+ // ── Types ──────────────────────────────────────────────────
30
+
31
+ export interface RegistryEntry {
32
+ name: string;
33
+ version: string;
34
+ purl: string;
35
+ source_type: ManifestPackage['source_type'];
36
+ confidence: number;
37
+ total_exports: number;
38
+ }
39
+
40
+ export interface RegistryIndex {
41
+ schema_version: number;
42
+ generated: string;
43
+ analyzer_version: string;
44
+ entries: RegistryEntry[];
45
+ }
46
+
47
+ export interface RegistryBuilderOptions {
48
+ /** Absolute path to the project root */
49
+ projectPath: string;
50
+ /** Absolute path to the registry output directory */
51
+ registryDir: string;
52
+ /** Path to effects-db directory (auto-detected if omitted) */
53
+ effectsDbPath?: string;
54
+ /** Skip packages exceeding this file count */
55
+ maxFiles?: number;
56
+ /** Per-package timeout in ms (default: 120000) */
57
+ timeout?: number;
58
+ /** Force rebuild even if manifest exists */
59
+ force?: boolean;
60
+ /** Verbose logging */
61
+ verbose?: boolean;
62
+ /** Packages to skip */
63
+ skip?: string[];
64
+ }
65
+
66
+ export interface BuildResult {
67
+ name: string;
68
+ version: string;
69
+ success: boolean;
70
+ manifestPath?: string;
71
+ error?: string;
72
+ durationMs: number;
73
+ }
74
+
75
+ type LogFn = (...args: unknown[]) => void;
76
+
77
+ // ── Package Resolution ─────────────────────────────────────
78
+
79
+ /**
80
+ * Resolve a package's directory from the project root.
81
+ *
82
+ * Strategy:
83
+ * 1. Try createRequire from project root (works for direct deps)
84
+ * 2. Try createRequire from each workspace package (pnpm strict mode)
85
+ * 3. Walk pnpm store directly (fallback for deeply nested deps)
86
+ */
87
+ export function resolvePackageDir(packageName: string, projectPath: string): string | null {
88
+ // Collect resolution roots: project root + workspace packages
89
+ const roots = [projectPath];
90
+
91
+ // Discover workspace packages for pnpm monorepo resolution
92
+ const pnpmWorkspace = join(projectPath, 'pnpm-workspace.yaml');
93
+ if (existsSync(pnpmWorkspace)) {
94
+ // Quick scan: find package.json files in packages/*/
95
+ const packagesDir = join(projectPath, 'packages');
96
+ if (existsSync(packagesDir)) {
97
+ try {
98
+ const entries = readdirSync(packagesDir, { withFileTypes: true });
99
+ for (const entry of entries) {
100
+ if (entry.isDirectory()) {
101
+ const pkgPath = join(packagesDir, entry.name);
102
+ if (existsSync(join(pkgPath, 'package.json'))) {
103
+ roots.push(pkgPath);
104
+ }
105
+ }
106
+ }
107
+ } catch {
108
+ // Ignore readdir errors
109
+ }
110
+ }
111
+ }
112
+
113
+ // Try createRequire from each root
114
+ for (const root of roots) {
115
+ const resolved = resolvePackageFromRoot(packageName, root);
116
+ if (resolved) return resolved;
117
+ }
118
+
119
+ // Fallback: walk pnpm store directly
120
+ return resolveFromPnpmStore(packageName, projectPath);
121
+ }
122
+
123
+ function resolvePackageFromRoot(packageName: string, root: string): string | null {
124
+ const req = createRequire(join(root, 'package.json'));
125
+
126
+ // Strategy 1: resolve <pkg>/package.json directly
127
+ try {
128
+ const pkgJsonPath = req.resolve(`${packageName}/package.json`);
129
+ const dir = dirname(pkgJsonPath);
130
+ // Verify this is the actual package root (not dist/cjs/package.json etc.)
131
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
132
+ if (pkg.name === packageName) return dir;
133
+ // Wrong package.json (e.g., dist/cjs/package.json with just {"type":"commonjs"})
134
+ // — walk up from this location to find the real root
135
+ return walkUpToPackageRoot(packageName, dir);
136
+ } catch {
137
+ // package.json subpath not exported or not resolvable
138
+ }
139
+
140
+ // Strategy 2: resolve main entry, walk up to find package root
141
+ try {
142
+ const mainPath = req.resolve(packageName);
143
+ return walkUpToPackageRoot(packageName, dirname(mainPath));
144
+ } catch {
145
+ // Not resolvable from this root
146
+ }
147
+
148
+ return null;
149
+ }
150
+
151
+ function walkUpToPackageRoot(packageName: string, startDir: string): string | null {
152
+ let dir = startDir;
153
+ for (let i = 0; i < 10; i++) {
154
+ if (existsSync(join(dir, 'package.json'))) {
155
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
156
+ if (pkg.name === packageName) return dir;
157
+ }
158
+ const parent = dirname(dir);
159
+ if (parent === dir) break;
160
+ dir = parent;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function resolveFromPnpmStore(packageName: string, projectPath: string): string | null {
166
+ const nodeModules = join(projectPath, 'node_modules');
167
+ const pnpmDir = join(nodeModules, '.pnpm');
168
+ if (!existsSync(pnpmDir)) return null;
169
+
170
+ try {
171
+ // pnpm store structure: .pnpm/<name>@<version>/node_modules/<name>/
172
+ // For scoped: .pnpm/@scope+name@<version>/node_modules/@scope/name/
173
+ const escapedName = packageName.replace('/', '+');
174
+ const entries = readdirSync(pnpmDir);
175
+ for (const entry of entries) {
176
+ if (entry.startsWith(`${escapedName}@`)) {
177
+ const candidate = join(pnpmDir, entry, 'node_modules', packageName);
178
+ if (existsSync(join(candidate, 'package.json'))) {
179
+ return candidate;
180
+ }
181
+ }
182
+ }
183
+ } catch {
184
+ // Ignore store access errors
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Detect source type of an npm package.
192
+ *
193
+ * - `dts_only`: Only .d.ts files (e.g., @types/node)
194
+ * - `compiled_js`: Standard npm package with JS output
195
+ * - `minified`: Bundled output with runtime helpers (esbuild, webpack, rollup)
196
+ * - `source`: Has src/ with .ts files (rare for npm packages)
197
+ */
198
+ export function detectSourceType(packageDir: string): ManifestPackage['source_type'] {
199
+ const pkgJsonPath = join(packageDir, 'package.json');
200
+ if (!existsSync(pkgJsonPath)) return 'compiled_js';
201
+
202
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
203
+
204
+ // @types/* packages are .d.ts only
205
+ if (pkgJson.name?.startsWith('@types/')) return 'dts_only';
206
+
207
+ // Check for types-only (e.g., has "types" but no "main"/"exports")
208
+ if (pkgJson.types && !pkgJson.main && !pkgJson.exports) return 'dts_only';
209
+
210
+ // Check for source TypeScript
211
+ if (existsSync(join(packageDir, 'src', 'index.ts'))) return 'source';
212
+
213
+ // Detect bundler output (esbuild, webpack, rollup) by checking entry file
214
+ const entryPoint = resolveEntryPoint(pkgJson);
215
+ if (entryPoint) {
216
+ const entryPath = join(packageDir, entryPoint.replace(/^\.\//, ''));
217
+ if (existsSync(entryPath)) {
218
+ try {
219
+ // Read first 500 bytes — bundler signatures are in the header
220
+ const fd = readFileSync(entryPath, { encoding: 'utf-8', flag: 'r' });
221
+ const header = fd.slice(0, 500);
222
+ // esbuild: var __defProp = Object.defineProperty; var __export = ...
223
+ // webpack: /******/ (() => { // webpackBootstrap
224
+ // rollup: usually clean, not detectable this way
225
+ if (header.includes('var __defProp = Object.defineProperty') ||
226
+ header.includes('__webpack_require__') ||
227
+ header.includes('webpackBootstrap')) {
228
+ return 'minified';
229
+ }
230
+ } catch {
231
+ // Ignore read errors
232
+ }
233
+ }
234
+ }
235
+
236
+ return 'compiled_js';
237
+ }
238
+
239
+ /**
240
+ * Resolve entry point from package.json fields.
241
+ * Follows Node.js resolution order: exports["."] → main → index.js
242
+ */
243
+ export function resolveEntryPoint(pkgJson: Record<string, unknown>): string | null {
244
+ // 1. exports["."]
245
+ const exports = pkgJson.exports as Record<string, unknown> | undefined;
246
+ if (exports) {
247
+ const dot = exports['.'];
248
+ if (typeof dot === 'string') return dot;
249
+ if (dot && typeof dot === 'object') {
250
+ const dotObj = dot as Record<string, unknown>;
251
+ // Prefer: import → require → default
252
+ const entry = dotObj.import ?? dotObj.require ?? dotObj.default;
253
+ if (typeof entry === 'string') return entry;
254
+ // Nested conditions (e.g., { import: { types: ..., default: ... } })
255
+ if (entry && typeof entry === 'object') {
256
+ const nested = entry as Record<string, string>;
257
+ if (nested.default) return nested.default;
258
+ }
259
+ }
260
+ }
261
+
262
+ // 2. main field
263
+ if (typeof pkgJson.main === 'string') return pkgJson.main;
264
+
265
+ // 3. module field (ESM)
266
+ if (typeof pkgJson.module === 'string') return pkgJson.module;
267
+
268
+ return null;
269
+ }
270
+
271
+ // ── RegistryBuilder ────────────────────────────────────────
272
+
273
+ export class RegistryBuilder {
274
+ private options: Required<Omit<RegistryBuilderOptions, 'effectsDbPath' | 'skip'>> & {
275
+ effectsDbPath?: string;
276
+ skip: string[];
277
+ };
278
+ private results: BuildResult[] = [];
279
+ private info: LogFn;
280
+ private debug: LogFn;
281
+
282
+ constructor(options: RegistryBuilderOptions) {
283
+ this.options = {
284
+ projectPath: resolve(options.projectPath),
285
+ registryDir: resolve(options.registryDir),
286
+ effectsDbPath: options.effectsDbPath,
287
+ maxFiles: options.maxFiles ?? 5000,
288
+ timeout: options.timeout ?? 600_000,
289
+ force: options.force ?? false,
290
+ verbose: options.verbose ?? false,
291
+ skip: options.skip ?? [],
292
+ };
293
+
294
+ this.info = console.log;
295
+ this.debug = this.options.verbose ? console.log : () => {};
296
+ }
297
+
298
+ /**
299
+ * Discover all external (non-workspace) dependencies from package.json
300
+ * files in the project, including workspace packages.
301
+ */
302
+ discoverDependencies(): string[] {
303
+ const deps = new Set<string>();
304
+
305
+ const collectDeps = (pkgPath: string) => {
306
+ if (!existsSync(pkgPath)) return;
307
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
308
+ for (const key of ['dependencies', 'devDependencies']) {
309
+ const section = pkg[key] as Record<string, string> | undefined;
310
+ if (!section) continue;
311
+ for (const [name, version] of Object.entries(section)) {
312
+ if (version.startsWith('workspace:')) continue;
313
+ deps.add(name);
314
+ }
315
+ }
316
+ };
317
+
318
+ // Root package.json
319
+ collectDeps(join(this.options.projectPath, 'package.json'));
320
+
321
+ // Workspace packages (pnpm monorepo)
322
+ const packagesDir = join(this.options.projectPath, 'packages');
323
+ if (existsSync(packagesDir)) {
324
+ try {
325
+ const entries = readdirSync(packagesDir, { withFileTypes: true });
326
+ for (const entry of entries) {
327
+ if (entry.isDirectory()) {
328
+ collectDeps(join(packagesDir, entry.name, 'package.json'));
329
+ }
330
+ }
331
+ } catch {
332
+ // Ignore readdir errors
333
+ }
334
+ }
335
+
336
+ return [...deps].sort();
337
+ }
338
+
339
+ /**
340
+ * Build manifest for a single package.
341
+ */
342
+ async buildPackage(packageName: string): Promise<BuildResult> {
343
+ const startTime = Date.now();
344
+
345
+ if (this.options.skip.includes(packageName)) {
346
+ return this.recordResult(packageName, '', false, 'Skipped (in skip list)', startTime);
347
+ }
348
+
349
+ // 1. Resolve package directory
350
+ const packageDir = resolvePackageDir(packageName, this.options.projectPath);
351
+ if (!packageDir) {
352
+ return this.recordResult(packageName, '', false, 'Package not found in node_modules', startTime);
353
+ }
354
+
355
+ // 2. Read package.json
356
+ const pkgJsonPath = join(packageDir, 'package.json');
357
+ if (!existsSync(pkgJsonPath)) {
358
+ return this.recordResult(packageName, '', false, 'No package.json', startTime);
359
+ }
360
+
361
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
362
+ const version = pkgJson.version ?? '0.0.0';
363
+
364
+ // 3. Check if already built (unless --force)
365
+ const manifestDir = this.getManifestDir(packageName, version);
366
+ const manifestPath = join(manifestDir, 'manifest.yaml');
367
+ if (!this.options.force && existsSync(manifestPath)) {
368
+ this.debug(` [skip] ${packageName}@${version} — already built`);
369
+ return this.recordResult(packageName, version, true, undefined, startTime, manifestPath);
370
+ }
371
+
372
+ // 4. Detect source type
373
+ const sourceType = detectSourceType(packageDir);
374
+ if (sourceType === 'dts_only') {
375
+ this.debug(` [skip] ${packageName}@${version} — dts_only (no runtime code)`);
376
+ return this.recordResult(packageName, version, false, 'dts_only packages not analyzable', startTime);
377
+ }
378
+ if (sourceType === 'minified') {
379
+ // Emit a stub manifest — package exists but exports aren't statically resolvable
380
+ this.debug(` [stub] ${packageName}@${version} — bundled output, emitting stub manifest`);
381
+ const manifestDir = this.getManifestDir(packageName, version);
382
+ const manifestPath = join(manifestDir, 'manifest.yaml');
383
+ mkdirSync(manifestDir, { recursive: true });
384
+ const stub: Manifest = {
385
+ schema_version: 1,
386
+ analyzer_version: GRAFEMA_VERSION,
387
+ authored: false,
388
+ confidence: 0,
389
+ generated: new Date().toISOString(),
390
+ package: { purl: `pkg:npm/${packageName}@${version}`, source_type: 'minified' },
391
+ exports: [],
392
+ imports: [],
393
+ capabilities: { total_exports: 0, total_internal_symbols: 0, has_graph: false },
394
+ };
395
+ writeFileSync(manifestPath, stringifyYaml(stub), 'utf-8');
396
+ this.info(` [stub] ${packageName}@${version} — bundled/minified, exports not statically resolvable`);
397
+ return this.recordResult(packageName, version, true, undefined, startTime, manifestPath);
398
+ }
399
+
400
+ // 5. Resolve entry point
401
+ const entryPoint = resolveEntryPoint(pkgJson);
402
+ this.debug(` Entry: ${entryPoint ?? '(auto-detect)'}`);
403
+ this.debug(` Source: ${sourceType}`);
404
+ this.debug(` Dir: ${packageDir}`);
405
+
406
+ // 6. Analyze via orchestrator + generate manifest
407
+ try {
408
+ const manifest = await this.analyzeAndGenerate(packageName, version, packageDir, sourceType, entryPoint);
409
+
410
+ // 7. Write manifest to registry
411
+ mkdirSync(manifestDir, { recursive: true });
412
+ const yaml = ManifestGenerator.toYaml(manifest);
413
+ writeFileSync(manifestPath, yaml, 'utf-8');
414
+
415
+ this.info(` [ok] ${packageName}@${version} — ${manifest.exports.length} exports, confidence ${manifest.confidence.toFixed(2)}`);
416
+ return this.recordResult(packageName, version, true, undefined, startTime, manifestPath);
417
+ } catch (err) {
418
+ const message = err instanceof Error ? err.message : String(err);
419
+ this.info(` [fail] ${packageName}@${version} — ${message}`);
420
+ return this.recordResult(packageName, version, false, message, startTime);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Build manifests for all discovered dependencies.
426
+ */
427
+ async buildAll(): Promise<BuildResult[]> {
428
+ const deps = this.discoverDependencies();
429
+ this.info(`\nDiscovered ${deps.length} external dependencies\n`);
430
+
431
+ for (const dep of deps) {
432
+ this.info(`Building ${dep}...`);
433
+ await this.buildPackage(dep);
434
+ }
435
+
436
+ return this.results;
437
+ }
438
+
439
+ /**
440
+ * Build manifests for specific packages.
441
+ */
442
+ async buildPackages(names: string[]): Promise<BuildResult[]> {
443
+ for (const name of names) {
444
+ this.info(`Building ${name}...`);
445
+ await this.buildPackage(name);
446
+ }
447
+ return this.results;
448
+ }
449
+
450
+ /**
451
+ * Write index.yaml catalog from all manifests in the registry.
452
+ * Merges with existing index to preserve entries from previous runs.
453
+ */
454
+ writeIndex(): string {
455
+ const registryDir = this.options.registryDir;
456
+ if (!existsSync(registryDir)) {
457
+ mkdirSync(registryDir, { recursive: true });
458
+ }
459
+
460
+ // Load existing index entries (preserve from previous runs)
461
+ const indexPath = join(registryDir, 'index.yaml');
462
+ const existing = new Map<string, RegistryEntry>();
463
+ if (existsSync(indexPath)) {
464
+ try {
465
+ const prev = parseYaml(readFileSync(indexPath, 'utf-8')) as RegistryIndex;
466
+ if (prev?.entries) {
467
+ for (const entry of prev.entries) {
468
+ existing.set(`${entry.name}@${entry.version}`, entry);
469
+ }
470
+ }
471
+ } catch {
472
+ // Ignore malformed index
473
+ }
474
+ }
475
+
476
+ // Add/update entries from current results
477
+ for (const result of this.results) {
478
+ if (!result.success || !result.manifestPath) continue;
479
+ if (!existsSync(result.manifestPath)) continue;
480
+
481
+ try {
482
+ const raw = readFileSync(result.manifestPath, 'utf-8');
483
+ const manifest = parseYaml(raw) as Manifest;
484
+
485
+ existing.set(`${result.name}@${result.version}`, {
486
+ name: result.name,
487
+ version: result.version,
488
+ purl: manifest.package.purl,
489
+ source_type: manifest.package.source_type,
490
+ confidence: manifest.confidence,
491
+ total_exports: manifest.exports.length,
492
+ });
493
+ } catch {
494
+ // Skip malformed manifests
495
+ }
496
+ }
497
+
498
+ const index: RegistryIndex = {
499
+ schema_version: 1,
500
+ generated: new Date().toISOString(),
501
+ analyzer_version: GRAFEMA_VERSION,
502
+ entries: [...existing.values()].sort((a, b) => a.name.localeCompare(b.name)),
503
+ };
504
+
505
+ writeFileSync(indexPath, stringifyYaml(index), 'utf-8');
506
+ return indexPath;
507
+ }
508
+
509
+ /** Get all build results. */
510
+ getResults(): BuildResult[] {
511
+ return this.results;
512
+ }
513
+
514
+ /** Print summary table of build results. */
515
+ printSummary(): void {
516
+ const succeeded = this.results.filter(r => r.success);
517
+ const failed = this.results.filter(r => !r.success);
518
+
519
+ this.info(`\n${'─'.repeat(60)}`);
520
+ this.info(`Registry build complete`);
521
+ this.info(` Succeeded: ${succeeded.length}`);
522
+ this.info(` Failed: ${failed.length}`);
523
+
524
+ if (failed.length > 0) {
525
+ this.info(`\nFailed packages:`);
526
+ for (const r of failed) {
527
+ this.info(` ${r.name}: ${r.error}`);
528
+ }
529
+ }
530
+
531
+ const totalDuration = this.results.reduce((sum, r) => sum + r.durationMs, 0);
532
+ this.info(`\nTotal time: ${(totalDuration / 1000).toFixed(1)}s`);
533
+ }
534
+
535
+ // ── Private ────────────────────────────────────────────
536
+
537
+ private getManifestDir(name: string, version: string): string {
538
+ return join(this.options.registryDir, name, version);
539
+ }
540
+
541
+ private recordResult(
542
+ name: string,
543
+ version: string,
544
+ success: boolean,
545
+ error: string | undefined,
546
+ startTime: number,
547
+ manifestPath?: string,
548
+ ): BuildResult {
549
+ const result: BuildResult = {
550
+ name,
551
+ version,
552
+ success,
553
+ manifestPath,
554
+ error,
555
+ durationMs: Date.now() - startTime,
556
+ };
557
+ this.results.push(result);
558
+ return result;
559
+ }
560
+
561
+ private async analyzeAndGenerate(
562
+ packageName: string,
563
+ version: string,
564
+ packageDir: string,
565
+ sourceType: ManifestPackage['source_type'],
566
+ entryPoint: string | null,
567
+ ): Promise<Manifest> {
568
+ // Find orchestrator binary
569
+ let orchestratorBinary = findOrchestratorBinary();
570
+ if (!orchestratorBinary) {
571
+ const downloaded = await ensureBinary('grafema-orchestrator', null, this.debug);
572
+ if (downloaded) orchestratorBinary = downloaded;
573
+ }
574
+ if (!orchestratorBinary) {
575
+ throw new Error('grafema-orchestrator binary not found');
576
+ }
577
+
578
+ // Create temp workspace (short path to avoid Unix socket 104-byte limit)
579
+ const shortName = packageName.replace(/^@/, '').replace(/[/@]/g, '-').slice(0, 20);
580
+ const tmpBase = join(tmpdir(), `gr-${shortName}-${Date.now() % 100000}`);
581
+ const tmpGrafemaDir = join(tmpBase, '.grafema');
582
+ const tmpDbPath = join(tmpGrafemaDir, 'graph.rfdb');
583
+
584
+ mkdirSync(tmpGrafemaDir, { recursive: true });
585
+
586
+ // Copy package to temp directory (avoids Rust glob issues with pnpm store paths
587
+ // that contain @, +, and other special characters)
588
+ const tmpPkgDir = join(tmpBase, 'pkg');
589
+ cpSync(packageDir, tmpPkgDir, { recursive: true, filter: (src) => {
590
+ // Skip node_modules WITHIN the package and .map files to save space
591
+ // The src path starts with packageDir — only check the relative suffix
592
+ const rel = src.slice(packageDir.length);
593
+ if (rel.includes('node_modules')) return false;
594
+ if (rel.endsWith('.map')) return false;
595
+ return true;
596
+ }});
597
+
598
+ // Determine include patterns based on source type.
599
+ // For compiled_js: prefer .js only (avoid analyzing both .js and .mjs copies)
600
+ const includePatterns = sourceType === 'compiled_js'
601
+ ? ['**/*.js', '**/*.cjs']
602
+ : ['**/*.ts', '**/*.js', '**/*.mjs'];
603
+
604
+ const excludePatterns = [
605
+ '**/node_modules/**', '**/*.test.*', '**/*.spec.*',
606
+ '**/test/**', '**/tests/**',
607
+ ];
608
+
609
+ // Count JS files to compute adaptive timeout
610
+ const fileCount = countFilesRecursive(tmpPkgDir, includePatterns, excludePatterns);
611
+ // Base 60s + 2s per file, minimum 60s, capped at user's max timeout
612
+ const adaptiveTimeout = Math.min(
613
+ Math.max(60_000, 60_000 + fileCount * 2_000),
614
+ this.options.timeout,
615
+ );
616
+ this.debug(` Files: ~${fileCount}, timeout: ${(adaptiveTimeout / 1000).toFixed(0)}s`);
617
+
618
+ // Write temp config
619
+ const configContent = stringifyYaml({
620
+ root: tmpPkgDir,
621
+ include: includePatterns,
622
+ exclude: excludePatterns,
623
+ plugins: [],
624
+ });
625
+ const configPath = join(tmpBase, 'grafema.config.yaml');
626
+ writeFileSync(configPath, configContent, 'utf-8');
627
+
628
+ // Start temp RFDB
629
+ const backend = new RFDBServerBackend({
630
+ dbPath: tmpDbPath,
631
+ autoStart: true,
632
+ silent: !this.options.verbose,
633
+ clientName: 'registry',
634
+ });
635
+
636
+ try {
637
+ await backend.connect();
638
+
639
+ // Spawn orchestrator
640
+ const exitCode = await this.spawnOrchestrator(
641
+ orchestratorBinary,
642
+ configPath,
643
+ backend.socketPath,
644
+ adaptiveTimeout,
645
+ );
646
+
647
+ if (exitCode !== 0) {
648
+ throw new Error(`Orchestrator exited with code ${exitCode}`);
649
+ }
650
+
651
+ // Resolve effects-db path
652
+ const effectsDbPath = this.resolveEffectsDbPath();
653
+
654
+ // Normalize entry point (strip leading ./, ensure extension)
655
+ let entryFile = entryPoint?.replace(/^\.\//, '');
656
+ if (entryFile && !/\.\w+$/.test(entryFile)) {
657
+ // No extension (e.g., "index") — append .js for compiled packages
658
+ entryFile = `${entryFile}.js`;
659
+ }
660
+
661
+ // Generate manifest
662
+ const purl = `pkg:npm/${packageName}@${version}`;
663
+ const generator = new ManifestGenerator(backend, {
664
+ purl,
665
+ effectsDbPath,
666
+ grafemaDir: tmpGrafemaDir,
667
+ sourceType,
668
+ entryFile,
669
+ });
670
+
671
+ return await generator.generate();
672
+ } finally {
673
+ // Cleanup
674
+ if (backend.connected) {
675
+ await backend.close();
676
+ }
677
+ // Remove temp directory
678
+ try {
679
+ rmSync(tmpBase, { recursive: true, force: true });
680
+ } catch {
681
+ // Best-effort cleanup
682
+ }
683
+ }
684
+ }
685
+
686
+ private spawnOrchestrator(
687
+ binary: string,
688
+ configPath: string,
689
+ socketPath: string,
690
+ timeout?: number,
691
+ ): Promise<number> {
692
+ const effectiveTimeout = timeout ?? this.options.timeout;
693
+ return new Promise((resolvePromise, reject) => {
694
+ const args = ['analyze', '--config', configPath, '--socket', socketPath];
695
+
696
+ this.debug(` Spawning: ${binary} ${args.join(' ')}`);
697
+
698
+ const child = spawn(binary, args, {
699
+ stdio: ['ignore', this.options.verbose ? 'inherit' : 'ignore', this.options.verbose ? 'inherit' : 'ignore'],
700
+ env: {
701
+ ...process.env,
702
+ RUST_LOG: this.options.verbose ? 'info' : 'warn',
703
+ },
704
+ });
705
+
706
+ const timer = setTimeout(() => {
707
+ child.kill('SIGKILL');
708
+ reject(new Error(`Analysis timed out after ${effectiveTimeout / 1000}s`));
709
+ }, effectiveTimeout);
710
+
711
+ child.on('error', (err) => {
712
+ clearTimeout(timer);
713
+ reject(new Error(`Failed to spawn orchestrator: ${err.message}`));
714
+ });
715
+
716
+ child.on('close', (code) => {
717
+ clearTimeout(timer);
718
+ resolvePromise(code ?? 1);
719
+ });
720
+ });
721
+ }
722
+
723
+ private resolveEffectsDbPath(): string | undefined {
724
+ if (this.options.effectsDbPath && existsSync(this.options.effectsDbPath)) {
725
+ return this.options.effectsDbPath;
726
+ }
727
+
728
+ // Try common locations
729
+ const candidates = [
730
+ join(this.options.projectPath, 'effects-db'),
731
+ join(this.options.projectPath, '..', 'effects-db'),
732
+ ];
733
+
734
+ return candidates.find(p => existsSync(p));
735
+ }
736
+ }
737
+
738
+ /** Count files matching include patterns (quick estimate for timeout calculation). */
739
+ function countFilesRecursive(dir: string, includes: string[], _excludes: string[]): number {
740
+ const extensions = new Set<string>();
741
+ for (const pattern of includes) {
742
+ const match = pattern.match(/\*\.(\w+)$/);
743
+ if (match) extensions.add(`.${match[1]}`);
744
+ }
745
+ if (extensions.size === 0) return 0;
746
+
747
+ let count = 0;
748
+ const walk = (d: string) => {
749
+ try {
750
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
751
+ const fullPath = join(d, entry.name);
752
+ if (entry.isDirectory()) {
753
+ // Skip excluded directories
754
+ if (entry.name === 'node_modules' || entry.name === 'test' || entry.name === 'tests') continue;
755
+ walk(fullPath);
756
+ } else {
757
+ const ext = entry.name.slice(entry.name.lastIndexOf('.'));
758
+ if (extensions.has(ext) && !entry.name.includes('.test.') && !entry.name.includes('.spec.')) {
759
+ count++;
760
+ }
761
+ }
762
+ }
763
+ } catch {
764
+ // Ignore permission errors
765
+ }
766
+ };
767
+ walk(dir);
768
+ return count;
769
+ }