@ismaelsoilet/synapsejs 1.1.0

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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/bin/synapse.ts +919 -0
  4. package/package.json +70 -0
  5. package/src/client/components.tsx +508 -0
  6. package/src/client/context.tsx +62 -0
  7. package/src/client/hooks.ts +152 -0
  8. package/src/client/i18n.ts +40 -0
  9. package/src/client.ts +66 -0
  10. package/src/compiler/agent-diagnostic-json.ts +113 -0
  11. package/src/compiler/ast-daemon-compressor.ts +190 -0
  12. package/src/compiler/client-bundler.ts +356 -0
  13. package/src/compiler/context-bench.ts +191 -0
  14. package/src/compiler/contract.ts +260 -0
  15. package/src/compiler/db-schema-generator.ts +306 -0
  16. package/src/compiler/fields-parser.ts +267 -0
  17. package/src/compiler/impact-analyzer.ts +279 -0
  18. package/src/compiler/migration-runner.ts +555 -0
  19. package/src/compiler/oracle-runner.ts +293 -0
  20. package/src/compiler/scaffolder.ts +528 -0
  21. package/src/compiler/schema-dag.ts +137 -0
  22. package/src/compiler/schema-drift.ts +184 -0
  23. package/src/compiler/slice-discovery.ts +154 -0
  24. package/src/compiler/slice-splitter.ts +838 -0
  25. package/src/compiler/slice-templates.ts +1527 -0
  26. package/src/compiler/standalone-builder.ts +117 -0
  27. package/src/core/action-context.ts +152 -0
  28. package/src/core/config.ts +77 -0
  29. package/src/core/database-client.ts +246 -0
  30. package/src/core/database-factory.ts +46 -0
  31. package/src/core/image-optimizer.ts +83 -0
  32. package/src/core/index.ts +14 -0
  33. package/src/core/jobs.ts +53 -0
  34. package/src/core/machine-types.ts +75 -0
  35. package/src/core/oracle.ts +20 -0
  36. package/src/core/postgres-client.ts +164 -0
  37. package/src/core/postgres-queue.ts +288 -0
  38. package/src/core/query-builder.ts +343 -0
  39. package/src/core/rpc-client.ts +70 -0
  40. package/src/core/session-context.ts +87 -0
  41. package/src/core/session-cookie.ts +81 -0
  42. package/src/core/session-token.ts +170 -0
  43. package/src/core/sqlite-client.ts +201 -0
  44. package/src/core/storage.ts +317 -0
  45. package/src/index.ts +133 -0
  46. package/src/mcp/server.ts +553 -0
  47. package/src/runtime/client-entry.ts +65 -0
  48. package/src/runtime/discovery-rules.ts +63 -0
  49. package/src/runtime/event-hub.ts +97 -0
  50. package/src/runtime/postgres-event-hub.ts +148 -0
  51. package/src/runtime/queue-engine.ts +255 -0
  52. package/src/runtime/rate-limiter.ts +152 -0
  53. package/src/runtime/server.ts +1550 -0
  54. package/src/runtime/uploads.ts +192 -0
  55. package/templates/starter/.dockerignore +7 -0
  56. package/templates/starter/Dockerfile +40 -0
  57. package/templates/starter/README.md +40 -0
  58. package/templates/starter/gitignore +6 -0
  59. package/templates/starter/package.json +34 -0
  60. package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
  61. package/templates/starter/tsconfig.json +21 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * SynapseJS - Client Bundle Builder
3
+ *
4
+ * The runtime builds a browser bundle per slice on demand; `synapse build` builds
5
+ * them ahead of time. Both go through this module, so what a deploy serves is
6
+ * byte-for-byte what development produced — there is no second implementation to
7
+ * keep in sync.
8
+ *
9
+ * The manifest is what makes the pre-build worth anything: it records the bundle
10
+ * URL next to the modification time of the slice it came from, so the runtime can
11
+ * serve the artifact without invoking the bundler, and rebuild the moment the slice
12
+ * changes.
13
+ */
14
+
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { Err, Ok, type Result } from '../core/machine-types';
18
+ import { clientEntrySource } from '../runtime/client-entry';
19
+ import { isComponent } from '../runtime/discovery-rules';
20
+ import { findSliceFiles, resolveSlicesDir, type SlicesDirError } from './slice-discovery';
21
+ import { artifactDirectory, splitSlice, verifySplit, writeSplitArtifacts } from './slice-splitter';
22
+
23
+ export const VENDOR_BUNDLE_NAME = '_vendor.js';
24
+ export const VENDOR_BUNDLE_URL = `/_synapse/client/${VENDOR_BUNDLE_NAME}`;
25
+
26
+ export interface BundleTarget {
27
+ key: string;
28
+ name: string;
29
+ domain: string;
30
+ filePath: string;
31
+ rpcPath: string;
32
+ vendorSplit?: boolean;
33
+ }
34
+
35
+ export interface BuiltBundle {
36
+ sliceKey: string;
37
+ url: string;
38
+ filePath: string;
39
+ bytes: number;
40
+ mtimeMs: number;
41
+ }
42
+
43
+ export interface BundleLeak {
44
+ sliceKey: string;
45
+ leaks: string[];
46
+ }
47
+
48
+ export type BuildErrorCode = 'IMPORT_FAILED' | 'SPLIT_FAILED' | 'NO_COMPONENT' | 'NO_CLIENT_ARTIFACT' | 'BUNDLE_FAILED';
49
+
50
+ export interface BuildError {
51
+ code: BuildErrorCode;
52
+ message: string;
53
+ }
54
+
55
+ export interface ClientManifest {
56
+ version: number;
57
+ bundles: Record<string, { url: string; mtimeMs: number }>;
58
+ }
59
+
60
+ export function clientManifestPath(baseDir: string): string {
61
+ return path.join(baseDir, '.synapse', 'client', 'manifest.json');
62
+ }
63
+
64
+ export async function buildVendorBundle(baseDir: string): Promise<Result<BuiltBundle, BuildError>> {
65
+ const vendorEntryDir = path.join(baseDir, '.synapse/client-entry');
66
+ fs.mkdirSync(vendorEntryDir, { recursive: true });
67
+ const vendorEntryPath = path.join(vendorEntryDir, '_vendor.ts');
68
+
69
+ const vendorEntryContent = [
70
+ `import React from 'react';`,
71
+ `import * as ReactDOM from 'react-dom';`,
72
+ `import * as ReactDOMClient from 'react-dom/client';`,
73
+ `import * as SynapseClient from 'synapsejs/client';`,
74
+ ``,
75
+ `export default React;`,
76
+ `export * from 'react';`,
77
+ `export * from 'react-dom';`,
78
+ `export * from 'react-dom/client';`,
79
+ `export * from 'synapsejs/client';`,
80
+ `export { ReactDOM, ReactDOMClient, SynapseClient };`,
81
+ ``
82
+ ].join('\n');
83
+
84
+ const existingEntry = fs.existsSync(vendorEntryPath) ? fs.readFileSync(vendorEntryPath, 'utf-8') : null;
85
+ if (existingEntry !== vendorEntryContent) {
86
+ fs.writeFileSync(vendorEntryPath, vendorEntryContent, 'utf-8');
87
+ }
88
+
89
+ const buildDir = path.join(baseDir, '.synapse/client');
90
+ fs.mkdirSync(buildDir, { recursive: true });
91
+ const vendorFile = path.join(buildDir, VENDOR_BUNDLE_NAME);
92
+
93
+ if (fs.existsSync(vendorFile) && existingEntry === vendorEntryContent) {
94
+ return Ok({
95
+ sliceKey: '_vendor',
96
+ url: VENDOR_BUNDLE_URL,
97
+ filePath: vendorFile,
98
+ bytes: fs.statSync(vendorFile).size,
99
+ mtimeMs: fs.statSync(vendorEntryPath).mtimeMs
100
+ });
101
+ }
102
+
103
+ const production = isProduction();
104
+
105
+ try {
106
+ const result = await Bun.build({
107
+ entrypoints: [vendorEntryPath],
108
+ target: 'browser',
109
+ outdir: buildDir,
110
+ naming: VENDOR_BUNDLE_NAME,
111
+ minify: production,
112
+ define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"' }
113
+ });
114
+
115
+ const output = result.outputs[0];
116
+ if (!result.success || !output) {
117
+ return Err({
118
+ code: 'BUNDLE_FAILED',
119
+ message: `Falha ao empacotar vendor: ${result.logs.map((log) => String(log)).join('; ') || 'sem diagnóstico'}`
120
+ });
121
+ }
122
+
123
+ return Ok({
124
+ sliceKey: '_vendor',
125
+ url: VENDOR_BUNDLE_URL,
126
+ filePath: output.path,
127
+ bytes: output.size,
128
+ mtimeMs: fs.statSync(vendorEntryPath).mtimeMs
129
+ });
130
+ } catch (err) {
131
+ return Err({ code: 'BUNDLE_FAILED', message: `_vendor: ${(err as Error).message}` });
132
+ }
133
+ }
134
+
135
+ export function isProduction(): boolean {
136
+ return process.env.NODE_ENV === 'production';
137
+ }
138
+
139
+ /** The component name the runtime would render, read from the running module. */
140
+ export function componentExportOf(module: Record<string, unknown>): string | undefined {
141
+ return Object.entries(module).find(
142
+ ([exportName, value]) => typeof value === 'function' && isComponent(exportName)
143
+ )?.[0];
144
+ }
145
+
146
+ export async function buildClientBundle(
147
+ target: BundleTarget,
148
+ baseDir: string
149
+ ): Promise<Result<BuiltBundle, BuildError>> {
150
+ // Uma fatia que não carrega não pode derrubar o build inteiro: ela vira FAIL com o motivo.
151
+ let sliceModule: Record<string, unknown>;
152
+ try {
153
+ sliceModule = (await import(target.filePath)) as Record<string, unknown>;
154
+ } catch (err) {
155
+ return Err({ code: 'IMPORT_FAILED', message: `${target.key}: ${(err as Error).message}` });
156
+ }
157
+
158
+ const componentExport = componentExportOf(sliceModule);
159
+
160
+ if (!componentExport) {
161
+ return Err({
162
+ code: 'NO_COMPONENT',
163
+ message: `${target.key} não exporta nenhum *Trigger|*View|*Form|*Component`
164
+ });
165
+ }
166
+
167
+ const split = splitSlice(target.filePath, baseDir);
168
+ if (!split.ok) {
169
+ return Err({ code: 'SPLIT_FAILED', message: split.error.message });
170
+ }
171
+
172
+ const outDir = artifactDirectory(baseDir, split.value.sliceName);
173
+ writeSplitArtifacts(split.value, outDir);
174
+
175
+ const clientArtifact = split.value.artifacts.find((artifact) => artifact.kind === 'client');
176
+ if (!clientArtifact) {
177
+ return Err({ code: 'NO_CLIENT_ARTIFACT', message: `${target.key} não produziu um módulo de cliente` });
178
+ }
179
+
180
+ // O gate roda antes de empacotar: sem isto um vazamento só apareceria como falha
181
+ // obscura de bundle, ou pior, seria servido ao browser sem ninguém notar.
182
+ const verification = verifySplit(split.value, outDir);
183
+ if (verification.status === 'FAIL') {
184
+ const diagnostic = verification.diagnostics[0];
185
+ const detail = verification.leaks.length
186
+ ? `vazamento no cliente: ${verification.leaks.join(', ')}`
187
+ : `erro de compilação: ${diagnostic?.file ?? '?'}:${diagnostic?.line ?? 0} ${diagnostic?.message ?? ''}`;
188
+
189
+ return Err({ code: 'SPLIT_FAILED', message: `${target.key}: ${detail}` });
190
+ }
191
+
192
+ const entryDir = path.join(baseDir, '.synapse/client-entry');
193
+ fs.mkdirSync(entryDir, { recursive: true });
194
+ const entryPath = path.join(entryDir, `${target.domain}-${target.name}.tsx`);
195
+ const modulePath = path.relative(entryDir, path.join(outDir, clientArtifact.fileName)).split(path.sep).join('/');
196
+
197
+ fs.writeFileSync(
198
+ entryPath,
199
+ clientEntrySource({
200
+ componentName: componentExport,
201
+ clientModulePath: `./${modulePath}`,
202
+ rpcPath: target.rpcPath
203
+ }),
204
+ 'utf-8'
205
+ );
206
+
207
+ const vendorSplit = target.vendorSplit ?? true;
208
+ if (vendorSplit) {
209
+ const vendorFile = path.join(baseDir, '.synapse/client', VENDOR_BUNDLE_NAME);
210
+ if (!fs.existsSync(vendorFile)) {
211
+ await buildVendorBundle(baseDir);
212
+ }
213
+ }
214
+
215
+ // Em produção o bundle é minificado e usa a build de produção do React (~4x menor).
216
+ const production = isProduction();
217
+ const buildDir = path.join(baseDir, '.synapse/client');
218
+ let result: Awaited<ReturnType<typeof Bun.build>>;
219
+ try {
220
+ result = await Bun.build({
221
+ entrypoints: [entryPath],
222
+ target: 'browser',
223
+ outdir: buildDir,
224
+ naming: '[name].js',
225
+ minify: production,
226
+ external: vendorSplit
227
+ ? ['react', 'react-dom', 'react-dom/client', 'synapsejs/client', '@ismaelsoilet/synapsejs/client']
228
+ : [],
229
+ define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"' }
230
+ });
231
+ } catch (err) {
232
+ // Bun.build pode lançar AggregateError em vez de retornar success:false: o motivo
233
+ // real está nos erros agregados, e um deles diz qual módulo não resolveu.
234
+ const aggregated = (err as { errors?: unknown[] }).errors ?? [];
235
+ const details = aggregated.map((item) => String((item as Error)?.message ?? item)).join('; ');
236
+
237
+ return Err({ code: 'BUNDLE_FAILED', message: `${target.key}: ${details || (err as Error).message}` });
238
+ }
239
+
240
+ const output = result.outputs[0];
241
+ if (!result.success || !output) {
242
+ return Err({
243
+ code: 'BUNDLE_FAILED',
244
+ message: `${target.key}: ${result.logs.map((log) => String(log)).join('; ') || 'sem diagnóstico'}`
245
+ });
246
+ }
247
+
248
+ return Ok({
249
+ sliceKey: target.key,
250
+ url: `/_synapse/client/${path.basename(output.path)}`,
251
+ filePath: output.path,
252
+ bytes: output.size,
253
+ mtimeMs: fs.statSync(target.filePath).mtimeMs
254
+ });
255
+ }
256
+
257
+ export function readClientManifest(baseDir: string): ClientManifest | null {
258
+ const manifestFile = clientManifestPath(baseDir);
259
+
260
+ if (!fs.existsSync(manifestFile)) {
261
+ return null;
262
+ }
263
+
264
+ try {
265
+ const parsed = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')) as ClientManifest;
266
+ return parsed?.version === 1 && typeof parsed.bundles === 'object' ? parsed : null;
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Writes the manifest sorted by slice key and without timestamps: the same slices
274
+ * always produce the same file, so a diff means something really changed.
275
+ */
276
+ export function writeClientManifest(baseDir: string, bundles: BuiltBundle[]): string {
277
+ const manifestFile = clientManifestPath(baseDir);
278
+ const bundlesByKey: ClientManifest['bundles'] = {};
279
+
280
+ for (const bundle of [...bundles].sort((left, right) => left.sliceKey.localeCompare(right.sliceKey))) {
281
+ bundlesByKey[bundle.sliceKey] = { url: bundle.url, mtimeMs: bundle.mtimeMs };
282
+ }
283
+
284
+ fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
285
+ fs.writeFileSync(manifestFile, `${JSON.stringify({ version: 1, bundles: bundlesByKey }, null, 2)}\n`, 'utf-8');
286
+
287
+ return manifestFile;
288
+ }
289
+
290
+ /** The pre-built bundle for a slice, but only while the slice has not changed since. */
291
+ export function freshBundleFrom(manifest: ClientManifest | null, sliceKey: string, mtimeMs: number): string | null {
292
+ const entry = manifest?.bundles[sliceKey];
293
+
294
+ return entry && entry.mtimeMs === mtimeMs ? entry.url : null;
295
+ }
296
+
297
+ export interface BuildReportEntry {
298
+ slice: string;
299
+ status: 'PASS' | 'SKIP' | 'FAIL';
300
+ url?: string;
301
+ bytes?: number;
302
+ reason?: string;
303
+ code?: string;
304
+ }
305
+
306
+ /**
307
+ * Builds every slice that renders a component, ahead of time.
308
+ *
309
+ * A slice without UI is reported as SKIP, not FAIL: only a slice that has a
310
+ * component and fails to produce a bundle is a broken build.
311
+ */
312
+ export async function buildAllClientBundles(
313
+ appRoot: string
314
+ ): Promise<Result<{ entries: BuildReportEntry[]; manifestFile: string }, SlicesDirError>> {
315
+ const resolution = resolveSlicesDir(appRoot);
316
+
317
+ if (!resolution.ok) {
318
+ return Err(resolution.error);
319
+ }
320
+
321
+ const slicesDir = resolution.value.slicesDir;
322
+ const entries: BuildReportEntry[] = [];
323
+ const built: BuiltBundle[] = [];
324
+
325
+ const vendorRes = await buildVendorBundle(appRoot);
326
+ if (vendorRes.ok) {
327
+ built.push(vendorRes.value);
328
+ entries.push({ slice: '_vendor', status: 'PASS', url: vendorRes.value.url, bytes: vendorRes.value.bytes });
329
+ }
330
+
331
+ for (const file of findSliceFiles(slicesDir)) {
332
+ const domain = path.relative(slicesDir, file).split(path.sep)[0] || 'general';
333
+ const name = path.basename(file, '.slice.tsx');
334
+ const key = `${domain}/${name}`;
335
+
336
+ const bundle = await buildClientBundle(
337
+ { key, name, domain, filePath: file, rpcPath: `/_synapse/rpc/${domain}/${name}` },
338
+ appRoot
339
+ );
340
+
341
+ if (bundle.ok) {
342
+ built.push(bundle.value);
343
+ entries.push({ slice: key, status: 'PASS', url: bundle.value.url, bytes: bundle.value.bytes });
344
+ continue;
345
+ }
346
+
347
+ if (bundle.error.code === 'NO_COMPONENT') {
348
+ entries.push({ slice: key, status: 'SKIP', reason: bundle.error.message });
349
+ continue;
350
+ }
351
+
352
+ entries.push({ slice: key, status: 'FAIL', code: bundle.error.code, reason: bundle.error.message });
353
+ }
354
+
355
+ return Ok({ entries, manifestFile: writeClientManifest(appRoot, built) });
356
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * SynapseJS - Context Surface Benchmark
3
+ *
4
+ * Measures what an agent must read to change one feature: the transitive import
5
+ * closure of that feature's entry point.
6
+ *
7
+ * Two numbers are reported per feature, because the boundary changes the answer:
8
+ *
9
+ * - `appFiles` — closure restricted to the app's own source. This is "how much
10
+ * app code an agent has to hold to make the change".
11
+ * - `totalFiles` — closure including sources reached through the project's own
12
+ * `paths` mapping (e.g. a framework resolved to its source
13
+ * directory). This is the framework cost, and it is paid once
14
+ * and shared across every feature.
15
+ *
16
+ * node_modules and `.d.ts` files are excluded from both: a published dependency
17
+ * is not app context, and counting it on one side only would rig the comparison.
18
+ *
19
+ * This measures context surface, not agent success rate.
20
+ */
21
+
22
+ import * as fs from 'fs';
23
+ import * as path from 'path';
24
+ import * as ts from 'typescript';
25
+
26
+ export const TOKENS_PER_BYTE_ESTIMATE = 0.25;
27
+
28
+ export interface FeatureSurface {
29
+ feature: string;
30
+ entries: string[];
31
+ appFiles: string[];
32
+ appBytes: number;
33
+ appTokensEstimate: number;
34
+ totalFiles: number;
35
+ totalBytes: number;
36
+ totalTokensEstimate: number;
37
+ }
38
+
39
+ export interface AppSurface {
40
+ app: string;
41
+ root: string;
42
+ features: FeatureSurface[];
43
+ }
44
+
45
+ interface Project {
46
+ program: ts.Program;
47
+ options: ts.CompilerOptions;
48
+ }
49
+
50
+ function loadProject(appRoot: string): Project {
51
+ const configPath = path.join(appRoot, 'tsconfig.json');
52
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
53
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, appRoot);
54
+
55
+ return {
56
+ program: ts.createProgram(parsed.fileNames, parsed.options),
57
+ options: parsed.options
58
+ };
59
+ }
60
+
61
+ function moduleSpecifiers(sourceFile: ts.SourceFile): string[] {
62
+ const specifiers: string[] = [];
63
+
64
+ for (const statement of sourceFile.statements) {
65
+ if (
66
+ (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) &&
67
+ statement.moduleSpecifier &&
68
+ ts.isStringLiteral(statement.moduleSpecifier)
69
+ ) {
70
+ specifiers.push(statement.moduleSpecifier.text);
71
+ }
72
+ }
73
+
74
+ return specifiers;
75
+ }
76
+
77
+ function isCountableSource(fileName: string): boolean {
78
+ if (fileName.endsWith('.d.ts')) {
79
+ return false;
80
+ }
81
+ if (fileName.includes(`${path.sep}node_modules${path.sep}`)) {
82
+ return false;
83
+ }
84
+ return /\.(ts|tsx|mts|cts)$/.test(fileName);
85
+ }
86
+
87
+ export function measureFeature(appRoot: string, feature: string, entries: string[], project?: Project): FeatureSurface {
88
+ const { program, options } = project ?? loadProject(appRoot);
89
+ const normalizedRoot = path.resolve(appRoot);
90
+ const appFiles = new Set<string>();
91
+ const allFiles = new Set<string>();
92
+ const queue = entries.map((entry) => path.resolve(appRoot, entry));
93
+
94
+ while (queue.length > 0) {
95
+ const file = path.normalize(queue.pop() as string);
96
+ if (allFiles.has(file)) {
97
+ continue;
98
+ }
99
+
100
+ allFiles.add(file);
101
+ const insideApp = file.startsWith(`${normalizedRoot}${path.sep}`);
102
+ if (insideApp) {
103
+ appFiles.add(file);
104
+ }
105
+
106
+ const sourceFile = program.getSourceFile(file);
107
+ if (!sourceFile) {
108
+ continue;
109
+ }
110
+
111
+ for (const specifier of moduleSpecifiers(sourceFile)) {
112
+ const resolved = ts.resolveModuleName(specifier, file, options, ts.sys).resolvedModule?.resolvedFileName;
113
+ if (!resolved) {
114
+ continue;
115
+ }
116
+
117
+ const normalized = path.normalize(resolved);
118
+ if (isCountableSource(normalized) && !allFiles.has(normalized)) {
119
+ queue.push(normalized);
120
+ }
121
+ }
122
+ }
123
+
124
+ const bytesOf = (files: Set<string>): number => {
125
+ let total = 0;
126
+ for (const file of files) {
127
+ total += fs.statSync(file).size;
128
+ }
129
+ return total;
130
+ };
131
+
132
+ const appBytes = bytesOf(appFiles);
133
+ const totalBytes = bytesOf(allFiles);
134
+
135
+ return {
136
+ feature,
137
+ entries,
138
+ appFiles: [...appFiles].map((file) => path.relative(appRoot, file)).sort(),
139
+ appBytes,
140
+ appTokensEstimate: Math.round(appBytes * TOKENS_PER_BYTE_ESTIMATE),
141
+ totalFiles: allFiles.size,
142
+ totalBytes,
143
+ totalTokensEstimate: Math.round(totalBytes * TOKENS_PER_BYTE_ESTIMATE)
144
+ };
145
+ }
146
+
147
+ interface BenchConfig {
148
+ features: Record<string, string[]>;
149
+ }
150
+
151
+ export function measureAppSurface(appRoot: string): AppSurface {
152
+ const configPath = path.join(appRoot, 'bench.config.json');
153
+ if (!fs.existsSync(configPath)) {
154
+ throw new Error(`bench.config.json ausente em ${appRoot}`);
155
+ }
156
+
157
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as BenchConfig;
158
+ const project = loadProject(appRoot);
159
+ const features = Object.entries(config.features ?? {}).map(([feature, entries]) =>
160
+ measureFeature(appRoot, feature, entries, project)
161
+ );
162
+
163
+ // A benchmark that measured nothing must not look like a result.
164
+ if (features.length === 0) {
165
+ throw new Error(`bench.config.json em ${appRoot} não declara nenhuma feature.`);
166
+ }
167
+
168
+ return {
169
+ app: path.basename(appRoot),
170
+ root: path.relative(process.cwd(), appRoot) || '.',
171
+ features
172
+ };
173
+ }
174
+
175
+ export function renderSurfaceReport(surfaces: AppSurface[]): string {
176
+ const lines = [
177
+ '| app | feature | arquivos (app) | tokens (app, est.) | arquivos (total) | tokens (total, est.) |',
178
+ '|---|---|---|---|---|---|'
179
+ ];
180
+
181
+ for (const surface of surfaces) {
182
+ for (const feature of surface.features) {
183
+ lines.push(
184
+ `| ${surface.app} | ${feature.feature} | ${feature.appFiles.length} | ${feature.appTokensEstimate} | ` +
185
+ `${feature.totalFiles} | ${feature.totalTokensEstimate} |`
186
+ );
187
+ }
188
+ }
189
+
190
+ return lines.join('\n');
191
+ }