@grafema/util 0.3.16 → 0.3.17

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.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * ManifestGenerator — generates manifest.yaml from a Grafema graph.
3
+ *
4
+ * Reads MODULE, EXPORT, EXTERNAL_MODULE, FUNCTION, CLASS nodes from the graph
5
+ * and assembles a Manifest object describing the package's export surface.
6
+ *
7
+ * Effects are looked up from the effects-db (builtins + packages) and
8
+ * propagated transitively via the call graph.
9
+ */
10
+
11
+ import { readFileSync, existsSync, readdirSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
14
+ import type { GraphBackend, NodeRecord } from '@grafema/types';
15
+ import { GRAFEMA_VERSION } from '../version.js';
16
+ import type {
17
+ Manifest,
18
+ ManifestExport,
19
+ ManifestImport,
20
+ EffectType,
21
+ ExportKind,
22
+ FlowType,
23
+ } from './types.js';
24
+
25
+ const SCHEMA_VERSION = 1;
26
+ const VALID_EFFECTS: Set<string> = new Set([
27
+ 'PURE', 'MUTATION', 'IO', 'THROW', 'ASYNC', 'NONDETERMINISTIC', 'UNKNOWN',
28
+ ]);
29
+
30
+ interface EffectsDB {
31
+ runtimes: Record<string, Record<string, EffectType[]>>;
32
+ packages: Record<string, Record<string, EffectType[]>>;
33
+ }
34
+
35
+ interface GeneratorOptions {
36
+ /** Package purl (e.g., "pkg:npm/@grafema/util@0.2.0") */
37
+ purl: string;
38
+ /** Path to effects-db directory */
39
+ effectsDbPath?: string;
40
+ /** Path to the .grafema directory */
41
+ grafemaDir: string;
42
+ /** Source type of analyzed code */
43
+ sourceType?: 'source' | 'compiled_js' | 'minified' | 'dts_only';
44
+ /** Files that belong to this package (prefix filter) */
45
+ packagePrefix?: string;
46
+ /** Graph-relative path to the entry file (e.g., "packages/util/src/index.ts").
47
+ * When set, collects exports via EXPORT → EXPORT_BINDING graph traversal,
48
+ * ensuring only the public API surface is included. */
49
+ entryFile?: string;
50
+ }
51
+
52
+ export class ManifestGenerator {
53
+ private backend: GraphBackend;
54
+ private options: GeneratorOptions;
55
+ private effectsDb: EffectsDB;
56
+ private effectsCache: Map<string, EffectType[]> = new Map();
57
+
58
+ constructor(backend: GraphBackend, options: GeneratorOptions) {
59
+ this.backend = backend;
60
+ this.options = options;
61
+ this.effectsDb = { runtimes: {}, packages: {} };
62
+ }
63
+
64
+ async generate(): Promise<Manifest> {
65
+ this.loadEffectsDB();
66
+
67
+ const exports = await this.collectExports();
68
+ await this.enrichEffects(exports);
69
+ const imports = await this.collectImports();
70
+ const totalInternal = await this.countInternalSymbols();
71
+
72
+ const manifest: Manifest = {
73
+ schema_version: SCHEMA_VERSION,
74
+ analyzer_version: GRAFEMA_VERSION,
75
+ authored: false,
76
+ confidence: this.computeConfidence(exports),
77
+ generated: new Date().toISOString(),
78
+
79
+ package: {
80
+ purl: this.options.purl,
81
+ source_type: this.options.sourceType ?? 'source',
82
+ },
83
+
84
+ exports,
85
+ imports,
86
+
87
+ capabilities: {
88
+ total_exports: exports.length,
89
+ total_internal_symbols: totalInternal,
90
+ has_graph: existsSync(join(this.options.grafemaDir, 'graph.rfdb')),
91
+ },
92
+
93
+ access: {
94
+ local: './graph.rfdb',
95
+ },
96
+
97
+ language: 'typescript',
98
+ language_specific: {
99
+ module_system: 'esm',
100
+ },
101
+ };
102
+
103
+ return manifest;
104
+ }
105
+
106
+ /** Serialize manifest to YAML string */
107
+ static toYaml(manifest: Manifest): string {
108
+ return stringifyYaml(manifest, {
109
+ lineWidth: 120,
110
+ defaultKeyType: 'PLAIN',
111
+ defaultStringType: 'PLAIN',
112
+ });
113
+ }
114
+
115
+ // ── Effects DB loading ─────────────────────────────────────
116
+
117
+ private loadEffectsDB(): void {
118
+ const dbPath = this.options.effectsDbPath;
119
+ if (!dbPath) return;
120
+
121
+ // Load runtime builtins
122
+ const nodeYaml = join(dbPath, 'runtimes', 'node.yaml');
123
+ if (existsSync(nodeYaml)) {
124
+ const raw = parseYaml(readFileSync(nodeYaml, 'utf-8')) as Record<string, Record<string, EffectType[]>>;
125
+ this.effectsDb.runtimes = raw;
126
+ }
127
+
128
+ // Load package effects
129
+ const pkgDir = join(dbPath, 'packages');
130
+ if (existsSync(pkgDir)) {
131
+ for (const file of readdirSync(pkgDir)) {
132
+ if (!file.endsWith('.yaml')) continue;
133
+ const raw = parseYaml(readFileSync(join(pkgDir, file), 'utf-8'));
134
+ if (raw && typeof raw === 'object') {
135
+ for (const [pkg, fns] of Object.entries(raw)) {
136
+ this.effectsDb.packages[pkg] = fns as Record<string, EffectType[]>;
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ /** Look up effects for a builtin or external function */
144
+ private lookupEffects(module: string, fn: string): EffectType[] | null {
145
+ // Check runtime builtins (node:fs → readFileSync)
146
+ const nodeModule = `node:${module}`;
147
+ if (this.effectsDb.runtimes[nodeModule]?.[fn]) {
148
+ return this.effectsDb.runtimes[nodeModule][fn];
149
+ }
150
+ // Also check without node: prefix
151
+ if (this.effectsDb.runtimes[module]?.[fn]) {
152
+ return this.effectsDb.runtimes[module][fn];
153
+ }
154
+
155
+ // Check package effects
156
+ if (this.effectsDb.packages[module]?.[fn]) {
157
+ return this.effectsDb.packages[module][fn];
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ // ── Export collection ──────────────────────────────────────
164
+
165
+ /** Definition node types we look up for exported symbols */
166
+ private static readonly DEF_TYPES = ['FUNCTION', 'CLASS', 'CONSTANT', 'INTERFACE'] as const;
167
+
168
+ private async collectExports(): Promise<ManifestExport[]> {
169
+ const seen = new Set<string>();
170
+ const exports: ManifestExport[] = [];
171
+ const entryFile = this.options.entryFile;
172
+
173
+ if (entryFile) {
174
+ // Graph-based approach:
175
+ // EXPORT(named) --EXPORTS--> EXPORT_BINDING(name, source) --> definition in source file
176
+ await this.collectExportsViaBindings(entryFile, exports, seen, new Set());
177
+ } else {
178
+ // Fallback: all exported definitions from the package
179
+ const prefix = this.options.packagePrefix ?? '';
180
+ for (const type of ManifestGenerator.DEF_TYPES) {
181
+ for await (const node of this.backend.queryNodes({ type: type as never })) {
182
+ if (prefix && !node.file?.startsWith(prefix)) continue;
183
+ if (!node.exported) continue;
184
+ if (!node.name || node.name.startsWith('<')) continue;
185
+ if (seen.has(node.name)) continue;
186
+ seen.add(node.name);
187
+ await this.addExportFromDefinition(node, exports);
188
+ }
189
+ }
190
+ }
191
+
192
+ exports.sort((a, b) => a.name.localeCompare(b.name));
193
+ return exports;
194
+ }
195
+
196
+ /** Collect exports by traversing EXPORT → EXPORT_BINDING graph edges from entry file */
197
+ private async collectExportsViaBindings(
198
+ file: string,
199
+ exports: ManifestExport[],
200
+ seen: Set<string>,
201
+ visitedFiles: Set<string>,
202
+ ): Promise<void> {
203
+ if (visitedFiles.has(file)) return;
204
+ visitedFiles.add(file);
205
+
206
+ for await (const exportNode of this.backend.queryNodes({ type: 'EXPORT' as never, file })) {
207
+ // Star re-exports: follow RE_EXPORTS edge to source module
208
+ if (exportNode.name?.startsWith('*:') || exportNode.name === '*') {
209
+ const reExportEdges = await this.backend.getOutgoingEdges(exportNode.id, ['RE_EXPORTS' as never]);
210
+ for (const edge of reExportEdges) {
211
+ const targetModule = await this.backend.getNode(edge.dst);
212
+ if (targetModule?.file) {
213
+ await this.collectExportsViaBindings(targetModule.file, exports, seen, visitedFiles);
214
+ }
215
+ }
216
+ continue;
217
+ }
218
+
219
+ // Named exports: follow EXPORTS edges to EXPORT_BINDING nodes
220
+ const bindingEdges = await this.backend.getOutgoingEdges(exportNode.id, ['EXPORTS' as never]);
221
+ for (const edge of bindingEdges) {
222
+ const binding = await this.backend.getNode(edge.dst);
223
+ if (!binding?.name || seen.has(binding.name)) continue;
224
+ seen.add(binding.name);
225
+
226
+ // Resolve the definition node from the source file
227
+ const source = (binding as Record<string, unknown>).source as string | undefined;
228
+ const defNode = source
229
+ ? await this.findDefinition(binding.name, source, file)
230
+ : await this.findDefinitionInFile(binding.name, file);
231
+
232
+ if (defNode) {
233
+ await this.addExportFromDefinition(defNode, exports);
234
+ } else {
235
+ // Type-only export or unresolvable — add as TYPE/VARIABLE
236
+ exports.push({
237
+ name: binding.name,
238
+ kind: 'TYPE',
239
+ semanticId: binding.id,
240
+ effects: ['PURE'],
241
+ });
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /** Find a definition node by name, resolving source path relative to the importing file.
248
+ * Follows barrel re-export chains: if the source file is a barrel (index.ts),
249
+ * looks for EXPORT_BINDING nodes there and follows their source recursively. */
250
+ private async findDefinition(
251
+ name: string, source: string, fromFile: string, depth = 0,
252
+ ): Promise<NodeRecord | null> {
253
+ if (depth > 5) return null;
254
+
255
+ const resolved = this.resolveSourcePath(source, fromFile);
256
+
257
+ // Direct definition in resolved file
258
+ const def = await this.findDefinitionInFile(name, resolved);
259
+ if (def) return def;
260
+
261
+ // Follow barrel re-export chain via EXPORT_BINDING
262
+ for await (const binding of this.backend.queryNodes({
263
+ type: 'EXPORT_BINDING' as never, name, file: resolved,
264
+ })) {
265
+ const nextSource = (binding as Record<string, unknown>).source as string | undefined;
266
+ if (nextSource) {
267
+ return this.findDefinition(name, nextSource, resolved, depth + 1);
268
+ }
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ /** Resolve a relative source path to a graph-relative file path */
275
+ private resolveSourcePath(source: string, fromFile: string): string {
276
+ const dir = fromFile.substring(0, fromFile.lastIndexOf('/'));
277
+ let resolved = source.startsWith('.') ? `${dir}/${source}` : source;
278
+ resolved = resolved.replace(/\/\.\//g, '/');
279
+ if (resolved.endsWith('.js')) {
280
+ resolved = resolved.replace(/\.js$/, '.ts');
281
+ }
282
+ return resolved;
283
+ }
284
+
285
+ /** Find a FUNCTION/CLASS/CONSTANT/INTERFACE node by name in a specific file */
286
+ private async findDefinitionInFile(name: string, file: string): Promise<NodeRecord | null> {
287
+ for (const type of ManifestGenerator.DEF_TYPES) {
288
+ for await (const node of this.backend.queryNodes({ type: type as never, name, file })) {
289
+ return node;
290
+ }
291
+ }
292
+ return null;
293
+ }
294
+
295
+ /** Build a ManifestExport entry from a definition node */
296
+ private async addExportFromDefinition(node: NodeRecord, exports: ManifestExport[]): Promise<void> {
297
+ const kind = this.nodeTypeToExportKind(node.type);
298
+ const entry: ManifestExport = {
299
+ name: node.name ?? '<unknown>',
300
+ kind,
301
+ semanticId: node.id,
302
+ effects: ['PURE'],
303
+ };
304
+
305
+ if (kind === 'FUNCTION' || kind === 'CLASS') {
306
+ const params = (node as Record<string, unknown>).params as string[] | undefined;
307
+ if (params && params.length > 0) {
308
+ entry.params = params.map(p => ({ name: p, flow: 'IN' as FlowType }));
309
+ }
310
+ entry.returns = { flow: 'OUT' };
311
+ }
312
+
313
+ exports.push(entry);
314
+ }
315
+
316
+ /** Map node type → ExportKind */
317
+ private nodeTypeToExportKind(type: string): ExportKind {
318
+ switch (type) {
319
+ case 'FUNCTION': return 'FUNCTION';
320
+ case 'CLASS': return 'CLASS';
321
+ case 'CONSTANT': return 'CONSTANT';
322
+ case 'INTERFACE': return 'INTERFACE';
323
+ default: return 'VARIABLE';
324
+ }
325
+ }
326
+
327
+
328
+ // ── Effect computation ─────────────────────────────────────
329
+
330
+ /** Enrich all exports with computed effects (transitive call graph analysis) */
331
+ private async enrichEffects(exports: ManifestExport[]): Promise<void> {
332
+ for (const entry of exports) {
333
+ if (entry.kind !== 'FUNCTION' && entry.kind !== 'CLASS') continue;
334
+
335
+ const effects = new Set<EffectType>();
336
+ const visited = new Set<string>();
337
+ await this.collectEffectsTransitively(entry.semanticId, effects, visited);
338
+
339
+ const computed = [...effects].filter(e => e !== 'PURE');
340
+ if (computed.length > 0) {
341
+ entry.effects = computed;
342
+ }
343
+ }
344
+ }
345
+
346
+ private async collectEffectsTransitively(
347
+ nodeId: string,
348
+ effects: Set<EffectType>,
349
+ visited: Set<string>,
350
+ depth = 0
351
+ ): Promise<void> {
352
+ if (visited.has(nodeId) || depth > 20) return;
353
+ visited.add(nodeId);
354
+
355
+ // Get outgoing CALLS edges
356
+ const callEdges = await this.backend.getOutgoingEdges(nodeId, ['CALLS' as never]);
357
+
358
+ for (const edge of callEdges) {
359
+ const targetNode = await this.backend.getNode(edge.dst);
360
+ if (!targetNode) continue;
361
+
362
+ // Check if target is an external call → look up in effects-db
363
+ if (targetNode.type === 'EXTERNAL_MODULE' || targetNode.type === 'GLOBAL_DEFINITION') {
364
+ const [module, fn] = this.parseCallTarget(targetNode);
365
+ if (module && fn) {
366
+ const knownEffects = this.lookupEffects(module, fn);
367
+ if (knownEffects) {
368
+ for (const e of knownEffects) {
369
+ if (VALID_EFFECTS.has(e) && e !== 'PURE') effects.add(e);
370
+ }
371
+ } else {
372
+ effects.add('UNKNOWN');
373
+ }
374
+ }
375
+ continue;
376
+ }
377
+
378
+ // Recurse into internal calls
379
+ await this.collectEffectsTransitively(edge.dst, effects, visited, depth + 1);
380
+ }
381
+
382
+ // Check node's own properties for async/throw
383
+ const node = await this.backend.getNode(nodeId);
384
+ if (node) {
385
+ const meta = node as Record<string, unknown>;
386
+ if (meta.async === true) effects.add('ASYNC');
387
+ const cf = meta.controlFlow as Record<string, boolean> | undefined;
388
+ if (cf?.hasThrow) effects.add('THROW');
389
+ if (cf?.canReject) effects.add('THROW');
390
+ }
391
+ }
392
+
393
+ private parseCallTarget(node: NodeRecord): [string | null, string | null] {
394
+ // Parse semantic ID like "EXTERNAL_MODULE:fs" or call name like "fs.readFileSync"
395
+ const name = node.name ?? '';
396
+ if (name.includes('.')) {
397
+ const parts = name.split('.');
398
+ return [parts[0], parts.slice(1).join('.')];
399
+ }
400
+ return [name, null];
401
+ }
402
+
403
+ // ── Import collection ──────────────────────────────────────
404
+
405
+ private async collectImports(): Promise<ManifestImport[]> {
406
+ const importMap = new Map<string, Set<string>>();
407
+ const prefix = this.options.packagePrefix ?? '';
408
+
409
+ for await (const node of this.backend.queryNodes({ type: 'IMPORT' as never })) {
410
+ if (prefix && !node.file?.startsWith(prefix)) continue;
411
+
412
+ const source = (node as Record<string, unknown>).source as string;
413
+ if (!source) continue;
414
+
415
+ // Skip relative imports (internal modules)
416
+ if (source.startsWith('.') || source.startsWith('/')) continue;
417
+
418
+ const purl = this.sourceToPurl(source);
419
+ if (!importMap.has(purl)) {
420
+ importMap.set(purl, new Set());
421
+ }
422
+
423
+ const importName = node.name ?? '*';
424
+ importMap.get(purl)!.add(importName);
425
+ }
426
+
427
+ return [...importMap.entries()]
428
+ .map(([purl, symbols]) => ({
429
+ purl,
430
+ symbols: [...symbols].sort(),
431
+ }))
432
+ .sort((a, b) => a.purl.localeCompare(b.purl));
433
+ }
434
+
435
+ private sourceToPurl(source: string): string {
436
+ // Node builtins: node:fs, node:path, etc.
437
+ if (source.startsWith('node:')) {
438
+ const mod = source.replace('node:', '');
439
+ return `pkg:npm/node@*#${mod}`;
440
+ }
441
+
442
+ // Scoped packages: @scope/name or @scope/name/path → pkg:npm/@scope/name
443
+ if (source.startsWith('@')) {
444
+ const parts = source.split('/');
445
+ const pkg = `${parts[0]}/${parts[1]}`;
446
+ return `pkg:npm/${pkg}`;
447
+ }
448
+
449
+ // Regular packages: name or name/path → pkg:npm/name
450
+ const pkg = source.split('/')[0];
451
+ return `pkg:npm/${pkg}`;
452
+ }
453
+
454
+ // ── Helpers ────────────────────────────────────────────────
455
+
456
+ private async countInternalSymbols(): Promise<number> {
457
+ const prefix = this.options.packagePrefix ?? '';
458
+ let count = 0;
459
+ for (const type of ['FUNCTION', 'CLASS', 'VARIABLE', 'CONSTANT', 'INTERFACE']) {
460
+ for await (const node of this.backend.queryNodes({ type: type as never })) {
461
+ if (prefix && !node.file?.startsWith(prefix)) continue;
462
+ count++;
463
+ }
464
+ }
465
+ return count;
466
+ }
467
+
468
+ private computeConfidence(exports: ManifestExport[]): number {
469
+ if (exports.length === 0) return 0;
470
+
471
+ const unknownCount = exports.filter(e =>
472
+ e.effects.includes('UNKNOWN')
473
+ ).length;
474
+
475
+ // confidence = 1.0 - (unknown_ratio * 0.5)
476
+ const unknownRatio = unknownCount / exports.length;
477
+ return Math.round((1.0 - unknownRatio * 0.5) * 100) / 100;
478
+ }
479
+ }
@@ -0,0 +1,16 @@
1
+ export { ManifestGenerator } from './generator.js';
2
+ export { ManifestResolver } from './resolver.js';
3
+ export type { ResolveResult, ManifestSummary } from './resolver.js';
4
+ export type {
5
+ Manifest,
6
+ ManifestExport,
7
+ ManifestImport,
8
+ ManifestParam,
9
+ ManifestCapabilities,
10
+ ManifestPackage,
11
+ ManifestAccess,
12
+ ManifestLanguageSpecific,
13
+ EffectType,
14
+ FlowType,
15
+ ExportKind,
16
+ } from './types.js';