@grafema/util 0.3.15 → 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,248 @@
1
+ /**
2
+ * ManifestResolver — resolves cross-package imports via manifest.yaml files.
3
+ *
4
+ * Given an import like `import { GraphBackend } from '@grafema/util'`,
5
+ * the resolver loads @grafema/util's manifest.yaml and returns the
6
+ * ManifestExport entry for GraphBackend (kind, effects, semanticId).
7
+ *
8
+ * This is the read-side complement to ManifestGenerator (write-side).
9
+ *
10
+ * Usage:
11
+ * const resolver = new ManifestResolver();
12
+ * resolver.loadFromWorkspace('/project', ['packages/util', 'packages/types']);
13
+ * resolver.loadFromNodeModules('/project/node_modules');
14
+ *
15
+ * const result = resolver.resolve('@grafema/util', 'GraphBackend');
16
+ * // → { name: 'GraphBackend', kind: 'CLASS', effects: ['PURE'], semanticId: '...' }
17
+ */
18
+
19
+ import { readFileSync, existsSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { parse as parseYaml } from 'yaml';
22
+ import type { Manifest, ManifestExport } from './types.js';
23
+
24
+ export interface ResolveResult {
25
+ /** The matched export entry */
26
+ export: ManifestExport;
27
+ /** Package purl this export belongs to */
28
+ packagePurl: string;
29
+ /** Confidence of the source manifest */
30
+ confidence: number;
31
+ /** Whether the manifest has a full graph available */
32
+ hasGraph: boolean;
33
+ }
34
+
35
+ export class ManifestResolver {
36
+ /** package name → loaded Manifest */
37
+ private manifests = new Map<string, Manifest>();
38
+ /** package name → Map<export name → ManifestExport> (index for O(1) lookup) */
39
+ private exportIndex = new Map<string, Map<string, ManifestExport>>();
40
+
41
+ /** Number of loaded manifests */
42
+ get size(): number {
43
+ return this.manifests.size;
44
+ }
45
+
46
+ /** All loaded package names */
47
+ get packages(): string[] {
48
+ return [...this.manifests.keys()];
49
+ }
50
+
51
+ /**
52
+ * Load a single manifest from a YAML file path.
53
+ * The package name is extracted from the manifest's purl field.
54
+ */
55
+ loadFromFile(filePath: string): Manifest | null {
56
+ if (!existsSync(filePath)) return null;
57
+
58
+ try {
59
+ const raw = readFileSync(filePath, 'utf-8');
60
+ const manifest = parseYaml(raw) as Manifest;
61
+ if (!manifest?.package?.purl || !manifest.exports) return null;
62
+
63
+ const pkgName = purlToPackageName(manifest.package.purl);
64
+ this.register(pkgName, manifest);
65
+ return manifest;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Load manifests from workspace packages.
73
+ * Scans each directory for manifest.yaml.
74
+ *
75
+ * @param rootDir Project root directory
76
+ * @param packageDirs Relative paths to package directories (e.g., ['packages/util', 'packages/types'])
77
+ */
78
+ loadFromWorkspace(rootDir: string, packageDirs: string[]): number {
79
+ let loaded = 0;
80
+ for (const dir of packageDirs) {
81
+ const manifestPath = join(rootDir, dir, 'manifest.yaml');
82
+ if (this.loadFromFile(manifestPath)) loaded++;
83
+ }
84
+ return loaded;
85
+ }
86
+
87
+ /**
88
+ * Auto-discover and load manifests from node_modules.
89
+ * Scans node_modules for packages containing manifest.yaml.
90
+ * Optionally scoped to specific package names.
91
+ *
92
+ * @param nodeModulesDir Path to node_modules directory
93
+ * @param packageNames Optional list of package names to load (e.g., ['@grafema/util']). If omitted, scans all.
94
+ */
95
+ loadFromNodeModules(nodeModulesDir: string, packageNames?: string[]): number {
96
+ if (!existsSync(nodeModulesDir)) return 0;
97
+
98
+ let loaded = 0;
99
+ if (packageNames) {
100
+ for (const name of packageNames) {
101
+ const manifestPath = join(nodeModulesDir, name, 'manifest.yaml');
102
+ if (this.loadFromFile(manifestPath)) loaded++;
103
+ }
104
+ }
105
+ return loaded;
106
+ }
107
+
108
+ /**
109
+ * Register a manifest directly (e.g., from in-memory generation).
110
+ */
111
+ register(packageName: string, manifest: Manifest): void {
112
+ this.manifests.set(packageName, manifest);
113
+
114
+ // Build export index
115
+ const index = new Map<string, ManifestExport>();
116
+ for (const exp of manifest.exports) {
117
+ index.set(exp.name, exp);
118
+ }
119
+ this.exportIndex.set(packageName, index);
120
+ }
121
+
122
+ /**
123
+ * Resolve an import: given package name and symbol, return the export info.
124
+ *
125
+ * @param source Import source (e.g., '@grafema/util', 'graphql-yoga')
126
+ * @param symbolName Exported symbol name (e.g., 'GraphBackend')
127
+ * @returns ResolveResult or null if not found
128
+ */
129
+ resolve(source: string, symbolName: string): ResolveResult | null {
130
+ const index = this.exportIndex.get(source);
131
+ if (!index) return null;
132
+
133
+ const exp = index.get(symbolName);
134
+ if (!exp) return null;
135
+
136
+ const manifest = this.manifests.get(source)!;
137
+ return {
138
+ export: exp,
139
+ packagePurl: manifest.package.purl,
140
+ confidence: manifest.confidence,
141
+ hasGraph: manifest.capabilities.has_graph,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Resolve all symbols from a specific import source.
147
+ * Returns a map of symbol name → ManifestExport for all known exports.
148
+ *
149
+ * Useful for: `import * as utils from '@grafema/util'`
150
+ */
151
+ resolveAll(source: string): Map<string, ManifestExport> | null {
152
+ return this.exportIndex.get(source) ?? null;
153
+ }
154
+
155
+ /**
156
+ * Get the full manifest for a package.
157
+ */
158
+ getManifest(packageName: string): Manifest | null {
159
+ return this.manifests.get(packageName) ?? null;
160
+ }
161
+
162
+ /**
163
+ * Check if a package has a loaded manifest.
164
+ */
165
+ has(packageName: string): boolean {
166
+ return this.manifests.has(packageName);
167
+ }
168
+
169
+ /**
170
+ * Get the effects for an imported symbol.
171
+ * Shorthand for resolve() when you only need effects.
172
+ */
173
+ getEffects(source: string, symbolName: string): string[] | null {
174
+ return this.resolve(source, symbolName)?.export.effects ?? null;
175
+ }
176
+
177
+ /**
178
+ * Build a dependency graph from loaded manifests.
179
+ * Returns: Map<packageName → Set<dependencyPackageName>>
180
+ */
181
+ buildDependencyGraph(): Map<string, Set<string>> {
182
+ const deps = new Map<string, Set<string>>();
183
+
184
+ for (const [name, manifest] of this.manifests) {
185
+ const pkgDeps = new Set<string>();
186
+ for (const imp of manifest.imports) {
187
+ const depName = purlToPackageName(imp.purl);
188
+ if (depName && this.manifests.has(depName)) {
189
+ pkgDeps.add(depName);
190
+ }
191
+ }
192
+ deps.set(name, pkgDeps);
193
+ }
194
+
195
+ return deps;
196
+ }
197
+
198
+ /**
199
+ * Get summary stats for all loaded manifests.
200
+ */
201
+ getSummary(): ManifestSummary[] {
202
+ const summaries: ManifestSummary[] = [];
203
+ for (const [name, manifest] of this.manifests) {
204
+ summaries.push({
205
+ packageName: name,
206
+ purl: manifest.package.purl,
207
+ totalExports: manifest.exports.length,
208
+ totalImports: manifest.imports.length,
209
+ confidence: manifest.confidence,
210
+ hasGraph: manifest.capabilities.has_graph,
211
+ exportsByKind: countByKind(manifest.exports),
212
+ });
213
+ }
214
+ return summaries.sort((a, b) => a.packageName.localeCompare(b.packageName));
215
+ }
216
+ }
217
+
218
+ export interface ManifestSummary {
219
+ packageName: string;
220
+ purl: string;
221
+ totalExports: number;
222
+ totalImports: number;
223
+ confidence: number;
224
+ hasGraph: boolean;
225
+ exportsByKind: Record<string, number>;
226
+ }
227
+
228
+ // ── Helpers ──────────────────────────────────────────────
229
+
230
+ /** Extract npm package name from a purl like "pkg:npm/@grafema/util@0.2.0" */
231
+ function purlToPackageName(purl: string): string {
232
+ // pkg:npm/@scope/name@version or pkg:npm/name@version
233
+ const match = purl.match(/^pkg:npm\/(.+?)(?:@[\d.]+.*)?$/);
234
+ if (!match) return purl;
235
+
236
+ const name = match[1];
237
+ // For node builtins like "pkg:npm/node@*#fs", skip
238
+ if (name === 'node') return '';
239
+ return name;
240
+ }
241
+
242
+ function countByKind(exports: ManifestExport[]): Record<string, number> {
243
+ const counts: Record<string, number> = {};
244
+ for (const e of exports) {
245
+ counts[e.kind] = (counts[e.kind] || 0) + 1;
246
+ }
247
+ return counts;
248
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Manifest format types for Grafema federation.
3
+ *
4
+ * A manifest describes a package's export surface: what it exports,
5
+ * what effects each export has, and what it imports.
6
+ *
7
+ * Manifest is the boundary contract between analyzed packages.
8
+ * It's polyglot (purl URIs), versioned (schema + analyzer),
9
+ * and layered (CDN < auto-generated < developer-authored).
10
+ */
11
+
12
+ // === Effect taxonomy (from effects-db/taxonomy.yaml) ===
13
+
14
+ export type EffectType =
15
+ | 'PURE'
16
+ | 'MUTATION'
17
+ | 'IO'
18
+ | 'THROW'
19
+ | 'ASYNC'
20
+ | 'NONDETERMINISTIC'
21
+ | 'UNKNOWN';
22
+
23
+ // === Flow types (universal across languages) ===
24
+
25
+ export type FlowType =
26
+ | 'IN' // read only
27
+ | 'OUT' // return value (new data)
28
+ | 'PIPE' // read and modified (pass-through mutation)
29
+ | 'SINK'; // consumed, not returned
30
+
31
+ // === Export kinds ===
32
+
33
+ export type ExportKind =
34
+ | 'FUNCTION'
35
+ | 'CLASS'
36
+ | 'VARIABLE'
37
+ | 'TYPE'
38
+ | 'CONSTANT'
39
+ | 'INTERFACE';
40
+
41
+ // === Manifest schema ===
42
+
43
+ export interface ManifestParam {
44
+ name: string;
45
+ flow: FlowType;
46
+ }
47
+
48
+ export interface ManifestExport {
49
+ name: string;
50
+ kind: ExportKind;
51
+ semanticId: string;
52
+ effects: EffectType[];
53
+ params?: ManifestParam[];
54
+ returns?: { flow: FlowType };
55
+ fields?: string[]; // for TYPE/INTERFACE exports
56
+ }
57
+
58
+ export interface ManifestImport {
59
+ purl: string;
60
+ symbols: string[];
61
+ }
62
+
63
+ export interface ManifestCapabilities {
64
+ total_exports: number;
65
+ total_internal_symbols: number;
66
+ has_graph: boolean;
67
+ }
68
+
69
+ export interface ManifestPackage {
70
+ purl: string;
71
+ checksum?: string;
72
+ source_type: 'source' | 'compiled_js' | 'minified' | 'dts_only';
73
+ }
74
+
75
+ export interface ManifestAccess {
76
+ local?: string;
77
+ remote?: string;
78
+ }
79
+
80
+ export interface ManifestLanguageSpecific {
81
+ module_system: 'esm' | 'cjs' | 'dual';
82
+ entry_points?: Record<string, string>;
83
+ typescript_declarations?: boolean;
84
+ }
85
+
86
+ export interface Manifest {
87
+ schema_version: number;
88
+ analyzer_version: string;
89
+ authored: boolean;
90
+ confidence: number;
91
+ generated: string; // ISO 8601
92
+
93
+ package: ManifestPackage;
94
+ exports: ManifestExport[];
95
+ imports: ManifestImport[];
96
+ capabilities: ManifestCapabilities;
97
+ access?: ManifestAccess;
98
+
99
+ language?: string;
100
+ language_specific?: ManifestLanguageSpecific;
101
+ }
@@ -142,6 +142,7 @@ export const EDGE_ARCHETYPE_MAP: Record<string, EdgeMapping> = {
142
142
  AFFECTS: m('governs', '|=', 'affects'),
143
143
  MONITORED_BY: m('governs', '|=', 'monitored by'),
144
144
  MEASURED_BY: m('governs', '|=', 'measured by'),
145
+ OBSERVES: m('governs', '|=', 'observes'),
145
146
  PROVISIONED_BY: m('governs', '|=', 'provisioned by'),
146
147
  REGISTERS_VIEW: m('governs', '|=', 'registers view'),
147
148
 
@@ -196,15 +196,37 @@ export async function downloadBinary(
196
196
  }
197
197
 
198
198
  /**
199
- * Check if a cached binary in ~/.grafema/bin/ is from the current release.
200
- * Returns true if the binary's .version file matches `binaries-v{GRAFEMA_VERSION}`.
199
+ * Extract semver from a binaries tag: "binaries-v0.3.14" "0.3.14"
200
+ */
201
+ function versionFromTag(tag: string): string {
202
+ return tag.replace(/^binaries-v/, '');
203
+ }
204
+
205
+ /**
206
+ * Compare two semver strings. Returns true if a >= b.
207
+ * Simple numeric comparison of major.minor.patch.
208
+ */
209
+ function semverGte(a: string, b: string): boolean {
210
+ const pa = a.split('-')[0].split('.').map(Number);
211
+ const pb = b.split('-')[0].split('.').map(Number);
212
+ for (let i = 0; i < 3; i++) {
213
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
214
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
215
+ }
216
+ return true; // equal
217
+ }
218
+
219
+ /**
220
+ * Check if a cached binary in ~/.grafema/bin/ is from a release >= current version.
221
+ * The .version file stores the release tag used for download (e.g. "binaries-v0.3.14").
201
222
  */
202
223
  function isBinaryCurrentVersion(binaryPath: string): boolean {
203
224
  const versionFile = `${binaryPath}.version`;
204
225
  if (!existsSync(versionFile)) return false;
205
226
  try {
206
227
  const storedTag = readFileSync(versionFile, 'utf-8').trim();
207
- return storedTag === `binaries-v${GRAFEMA_VERSION}`;
228
+ const storedVersion = versionFromTag(storedTag);
229
+ return semverGte(storedVersion, GRAFEMA_VERSION);
208
230
  } catch {
209
231
  return false;
210
232
  }
@@ -243,14 +265,14 @@ export async function ensureBinary(
243
265
  return null;
244
266
  }
245
267
 
246
- // Download (missing or stale)
268
+ // Download (missing or stale) — auto-detect latest binaries-v* release tag
247
269
  const log = onProgress || (() => {});
248
270
  if (cached) {
249
- log(`Updating ${binaryName} to binaries-v${GRAFEMA_VERSION}...`);
271
+ log(`Updating ${binaryName} (stale binary detected)...`);
250
272
  }
251
273
 
252
274
  try {
253
- return await downloadBinary(binaryName, `binaries-v${GRAFEMA_VERSION}`, onProgress);
275
+ return await downloadBinary(binaryName, undefined, onProgress);
254
276
  } catch (err) {
255
277
  const msg = err instanceof Error ? err.message : String(err);
256
278
  (onProgress || console.error)(`Failed to download ${binaryName}: ${msg}`);