@ball-lang/cli 0.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.
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Static capability analysis for Ball programs.
3
+ *
4
+ * Walks the expression tree of every function in a Program and reports
5
+ * which base functions are called, categorized by side-effect capability.
6
+ * Since every side effect in Ball flows through a named base function,
7
+ * this analysis is provably complete — not heuristic.
8
+ *
9
+ * Ported from `dart/shared/lib/capability_analyzer.dart`.
10
+ */
11
+
12
+ import {
13
+ ALL_CAPABILITIES,
14
+ capabilityRiskLevel,
15
+ lookupCapability,
16
+ } from './capability_table.ts';
17
+ import type { Capability } from './capability_table.ts';
18
+
19
+ // ── Ball program types (proto3 JSON shape) ──────────────────────────────────
20
+ // Intentionally permissive — these mirror the JSON structure of a Ball
21
+ // program. They match the engine's internal types but are redeclared here so
22
+ // the analyzer has no hard dependency on the engine's private types.
23
+
24
+ export interface Program {
25
+ name?: string;
26
+ version?: string;
27
+ modules: Module[];
28
+ entryModule: string;
29
+ entryFunction: string;
30
+ }
31
+
32
+ export interface Module {
33
+ name: string;
34
+ functions: FunctionDef[];
35
+ }
36
+
37
+ export interface FunctionDef {
38
+ name: string;
39
+ isBase?: boolean;
40
+ body?: Expression;
41
+ }
42
+
43
+ export interface Expression {
44
+ call?: FunctionCall;
45
+ literal?: Literal;
46
+ reference?: { name: string };
47
+ fieldAccess?: { object?: Expression; field: string };
48
+ messageCreation?: { fields: FieldValuePair[] };
49
+ block?: Block;
50
+ lambda?: Lambda;
51
+ }
52
+
53
+ export interface FunctionCall {
54
+ module?: string;
55
+ function: string;
56
+ input?: Expression;
57
+ }
58
+
59
+ export interface Literal {
60
+ listValue?: { elements: Expression[] };
61
+ // Other literal variants ignored — they contain no sub-expressions.
62
+ }
63
+
64
+ export interface FieldValuePair {
65
+ name: string;
66
+ value: Expression;
67
+ }
68
+
69
+ export interface Block {
70
+ statements: Statement[];
71
+ result?: Expression;
72
+ }
73
+
74
+ export interface Statement {
75
+ let?: { name: string; value: Expression };
76
+ expression?: Expression;
77
+ }
78
+
79
+ export interface Lambda {
80
+ body: Expression;
81
+ }
82
+
83
+ // ── Report types ────────────────────────────────────────────────────────────
84
+
85
+ export interface CallSite {
86
+ module: string;
87
+ function: string;
88
+ calleeModule: string;
89
+ calleeFunction: string;
90
+ }
91
+
92
+ export interface FunctionCapability {
93
+ module: string;
94
+ function: string;
95
+ capabilities: Capability[];
96
+ }
97
+
98
+ export interface CapabilityEntry {
99
+ capability: Capability;
100
+ riskLevel: string;
101
+ callSites: CallSite[];
102
+ }
103
+
104
+ export interface CapabilitySummary {
105
+ isPure: boolean;
106
+ readsFilesystem: boolean;
107
+ writesFilesystem: boolean;
108
+ readsStdin: boolean;
109
+ writesStdout: boolean;
110
+ writesStderr: boolean;
111
+ readsEnvironment: boolean;
112
+ controlsProcess: boolean;
113
+ usesMemory: boolean;
114
+ usesTime: boolean;
115
+ usesRandom: boolean;
116
+ usesConcurrency: boolean;
117
+ usesNetwork: boolean;
118
+ totalFunctions: number;
119
+ pureFunctions: number;
120
+ effectfulFunctions: number;
121
+ }
122
+
123
+ export interface BallCapabilityReport {
124
+ programName: string;
125
+ programVersion: string;
126
+ capabilities: CapabilityEntry[];
127
+ functions: FunctionCapability[];
128
+ summary: CapabilitySummary;
129
+ }
130
+
131
+ // ── Analyzer ────────────────────────────────────────────────────────────────
132
+
133
+ export interface AnalyzeOptions {
134
+ /** If true, only analyze functions transitively reachable from the entry. */
135
+ reachableOnly?: boolean;
136
+ }
137
+
138
+ /** Analyze a Ball program and return a structured capability report. */
139
+ export function analyzeCapabilities(
140
+ program: Program,
141
+ options: AnalyzeOptions = {},
142
+ ): BallCapabilityReport {
143
+ return new Analyzer(program, options.reachableOnly ?? false).analyze();
144
+ }
145
+
146
+ class Analyzer {
147
+ private readonly program: Program;
148
+ private readonly reachableOnly: boolean;
149
+ private readonly fnCaps = new Map<string, Set<Capability>>();
150
+ private readonly capCallSites = new Map<Capability, CallSite[]>();
151
+ private readonly baseModules = new Set<string>();
152
+
153
+ constructor(program: Program, reachableOnly: boolean) {
154
+ this.program = program;
155
+ this.reachableOnly = reachableOnly;
156
+ }
157
+
158
+ analyze(): BallCapabilityReport {
159
+ this.identifyBaseModules();
160
+ if (this.reachableOnly) {
161
+ this.analyzeReachable();
162
+ } else {
163
+ this.analyzeAll();
164
+ }
165
+ return this.buildReport();
166
+ }
167
+
168
+ private identifyBaseModules(): void {
169
+ for (const mod of this.program.modules) {
170
+ if (mod.functions.length === 0) continue;
171
+ const allBase = mod.functions.every((f) => f.isBase === true);
172
+ if (allBase) this.baseModules.add(mod.name);
173
+ }
174
+ }
175
+
176
+ private analyzeAll(): void {
177
+ for (const mod of this.program.modules) {
178
+ if (this.baseModules.has(mod.name)) continue;
179
+ for (const fn of mod.functions) {
180
+ if (fn.isBase) continue;
181
+ const caps = new Set<Capability>();
182
+ if (fn.body) this.walkExpression(fn.body, mod.name, fn.name, caps);
183
+ this.fnCaps.set(`${mod.name}.${fn.name}`, caps);
184
+ }
185
+ }
186
+ }
187
+
188
+ private analyzeReachable(): void {
189
+ const visited = new Set<string>();
190
+ const entryKey = `${this.program.entryModule}.${this.program.entryFunction}`;
191
+ this.analyzeFunction(entryKey, visited);
192
+ }
193
+
194
+ private analyzeFunction(key: string, visited: Set<string>): void {
195
+ if (visited.has(key)) return;
196
+ visited.add(key);
197
+
198
+ const parts = key.split('.');
199
+ if (parts.length < 2) return;
200
+ const moduleName = parts[0]!;
201
+ const fnName = parts.slice(1).join('.');
202
+
203
+ if (this.baseModules.has(moduleName)) return;
204
+
205
+ for (const mod of this.program.modules) {
206
+ if (mod.name !== moduleName) continue;
207
+ for (const fn of mod.functions) {
208
+ if (fn.name !== fnName) continue;
209
+ if (fn.isBase) return;
210
+ const caps = new Set<Capability>();
211
+ const callees = new Set<string>();
212
+ if (fn.body) {
213
+ this.walkExpression(fn.body, moduleName, fnName, caps, callees);
214
+ }
215
+ this.fnCaps.set(key, caps);
216
+ for (const callee of callees) {
217
+ this.analyzeFunction(callee, visited);
218
+ const calleeCaps = this.fnCaps.get(callee);
219
+ if (calleeCaps) for (const c of calleeCaps) caps.add(c);
220
+ }
221
+ return;
222
+ }
223
+ }
224
+ }
225
+
226
+ private walkExpression(
227
+ expr: Expression,
228
+ ctxModule: string,
229
+ ctxFunction: string,
230
+ caps: Set<Capability>,
231
+ callees?: Set<string>,
232
+ ): void {
233
+ if (expr.call) {
234
+ this.walkCall(expr.call, ctxModule, ctxFunction, caps, callees);
235
+ return;
236
+ }
237
+ if (expr.literal) {
238
+ if (expr.literal.listValue) {
239
+ for (const elem of expr.literal.listValue.elements) {
240
+ this.walkExpression(elem, ctxModule, ctxFunction, caps, callees);
241
+ }
242
+ }
243
+ return;
244
+ }
245
+ if (expr.block) {
246
+ for (const stmt of expr.block.statements) {
247
+ if (stmt.let) {
248
+ this.walkExpression(stmt.let.value, ctxModule, ctxFunction, caps, callees);
249
+ }
250
+ if (stmt.expression) {
251
+ this.walkExpression(stmt.expression, ctxModule, ctxFunction, caps, callees);
252
+ }
253
+ }
254
+ if (expr.block.result) {
255
+ this.walkExpression(expr.block.result, ctxModule, ctxFunction, caps, callees);
256
+ }
257
+ return;
258
+ }
259
+ if (expr.lambda) {
260
+ this.walkExpression(expr.lambda.body, ctxModule, ctxFunction, caps, callees);
261
+ return;
262
+ }
263
+ if (expr.messageCreation) {
264
+ for (const field of expr.messageCreation.fields) {
265
+ this.walkExpression(field.value, ctxModule, ctxFunction, caps, callees);
266
+ }
267
+ return;
268
+ }
269
+ if (expr.fieldAccess?.object) {
270
+ this.walkExpression(expr.fieldAccess.object, ctxModule, ctxFunction, caps, callees);
271
+ return;
272
+ }
273
+ // reference or unset: nothing to walk
274
+ }
275
+
276
+ private walkCall(
277
+ call: FunctionCall,
278
+ ctxModule: string,
279
+ ctxFunction: string,
280
+ caps: Set<Capability>,
281
+ callees?: Set<string>,
282
+ ): void {
283
+ const moduleName = call.module && call.module.length > 0 ? call.module : ctxModule;
284
+ const fnName = call.function;
285
+
286
+ const cap = lookupCapability(moduleName, fnName);
287
+ if (cap !== undefined) {
288
+ caps.add(cap);
289
+ if (cap !== 'pure') {
290
+ const list = this.capCallSites.get(cap) ?? [];
291
+ list.push({
292
+ module: ctxModule,
293
+ function: ctxFunction,
294
+ calleeModule: moduleName,
295
+ calleeFunction: fnName,
296
+ });
297
+ this.capCallSites.set(cap, list);
298
+ }
299
+ } else {
300
+ callees?.add(`${moduleName}.${fnName}`);
301
+ }
302
+
303
+ if (call.input) {
304
+ this.walkExpression(call.input, ctxModule, ctxFunction, caps, callees);
305
+ }
306
+ }
307
+
308
+ private buildReport(): BallCapabilityReport {
309
+ const allCaps = new Set<Capability>();
310
+ let totalFns = 0;
311
+ let pureFns = 0;
312
+ let effectfulFns = 0;
313
+
314
+ const functions: FunctionCapability[] = [];
315
+
316
+ for (const [key, caps] of this.fnCaps) {
317
+ const dot = key.indexOf('.');
318
+ const mod = key.substring(0, dot);
319
+ const fn = key.substring(dot + 1);
320
+ functions.push({
321
+ module: mod,
322
+ function: fn,
323
+ capabilities: Array.from(caps),
324
+ });
325
+
326
+ for (const c of caps) allCaps.add(c);
327
+ totalFns++;
328
+ const onlyPure = Array.from(caps).every((c) => c === 'pure');
329
+ if (onlyPure) pureFns++;
330
+ else effectfulFns++;
331
+ }
332
+
333
+ const capabilities: CapabilityEntry[] = [];
334
+ for (const cap of ALL_CAPABILITIES) {
335
+ if (!allCaps.has(cap) && cap !== 'pure') continue;
336
+ const sites = this.capCallSites.get(cap) ?? [];
337
+ if (cap === 'pure' && sites.length === 0 && allCaps.has(cap)) {
338
+ capabilities.push({
339
+ capability: cap,
340
+ riskLevel: capabilityRiskLevel[cap],
341
+ callSites: [],
342
+ });
343
+ continue;
344
+ }
345
+ if (sites.length > 0) {
346
+ capabilities.push({
347
+ capability: cap,
348
+ riskLevel: capabilityRiskLevel[cap],
349
+ callSites: sites.slice(),
350
+ });
351
+ }
352
+ }
353
+
354
+ const ioSites = this.capCallSites.get('io') ?? [];
355
+ const summary: CapabilitySummary = {
356
+ isPure: Array.from(allCaps).every((c) => c === 'pure'),
357
+ readsFilesystem: allCaps.has('fs'),
358
+ writesFilesystem: allCaps.has('fs'),
359
+ readsStdin: ioSites.some((s) => s.calleeFunction === 'read_line'),
360
+ writesStdout: ioSites.some(
361
+ (s) => s.calleeFunction === 'print' || s.calleeFunction === 'print_error',
362
+ ),
363
+ writesStderr: ioSites.some((s) => s.calleeFunction === 'print_error'),
364
+ readsEnvironment: ioSites.some(
365
+ (s) => s.calleeFunction === 'env_get' || s.calleeFunction === 'args_get',
366
+ ),
367
+ controlsProcess: allCaps.has('process'),
368
+ usesMemory: allCaps.has('memory'),
369
+ usesTime: allCaps.has('time'),
370
+ usesRandom: allCaps.has('random'),
371
+ usesConcurrency: allCaps.has('concurrency'),
372
+ usesNetwork: allCaps.has('network'),
373
+ totalFunctions: totalFns,
374
+ pureFunctions: pureFns,
375
+ effectfulFunctions: effectfulFns,
376
+ };
377
+
378
+ return {
379
+ programName: this.program.name ?? '',
380
+ programVersion: this.program.version ?? '',
381
+ capabilities,
382
+ functions,
383
+ summary,
384
+ };
385
+ }
386
+ }
387
+
388
+ // ── Formatting & policy helpers ─────────────────────────────────────────────
389
+
390
+ /** Format a capability report as human-readable text. */
391
+ export function formatCapabilityReport(report: BallCapabilityReport): string {
392
+ const lines: string[] = [];
393
+ const name = report.programName || '<unnamed>';
394
+ const version = report.programVersion || '0.0.0';
395
+ lines.push(`Ball Capability Audit: ${name} v${version}`);
396
+ lines.push('='.repeat(60));
397
+ lines.push('');
398
+
399
+ lines.push('Capabilities:');
400
+ for (const entry of report.capabilities) {
401
+ const icon = entry.riskLevel === 'none' ? '\u2713' : '\u26A0';
402
+ const siteCount = entry.callSites.length;
403
+ if (siteCount === 0) {
404
+ lines.push(` ${icon} ${entry.capability} (pure computation)`);
405
+ } else {
406
+ const sites = entry.callSites
407
+ .map(
408
+ (s) =>
409
+ `${s.module}.${s.function} \u2192 ${s.calleeModule}.${s.calleeFunction}`,
410
+ )
411
+ .join(', ');
412
+ lines.push(` ${icon} ${entry.capability} (${siteCount} call sites: ${sites})`);
413
+ }
414
+ }
415
+
416
+ const absent: string[] = [];
417
+ const s = report.summary;
418
+ if (!s.readsFilesystem && !s.writesFilesystem) absent.push('filesystem');
419
+ if (!s.usesNetwork) absent.push('network');
420
+ if (!s.controlsProcess) absent.push('process');
421
+ if (!s.usesMemory) absent.push('memory');
422
+ if (!s.usesConcurrency) absent.push('concurrency');
423
+ if (!s.usesRandom) absent.push('random');
424
+ if (absent.length > 0) {
425
+ lines.push(` \u2717 NONE: ${absent.join(', ')}`);
426
+ }
427
+
428
+ lines.push('');
429
+ const risk = s.isPure
430
+ ? 'NO RISK \u2014 pure computation only'
431
+ : s.controlsProcess || s.usesMemory || s.usesNetwork
432
+ ? 'HIGH RISK'
433
+ : s.readsFilesystem || s.writesFilesystem || s.usesConcurrency
434
+ ? 'MEDIUM RISK'
435
+ : 'LOW RISK';
436
+ lines.push(`Summary: ${risk}`);
437
+ lines.push(
438
+ ` ${s.totalFunctions} functions: ${s.pureFunctions} pure, ${s.effectfulFunctions} effectful`,
439
+ );
440
+
441
+ lines.push('');
442
+ lines.push('Per-function breakdown:');
443
+ for (const fn of report.functions) {
444
+ const caps = fn.capabilities.filter((c) => c !== 'pure');
445
+ const label = caps.length === 0 ? 'pure' : caps.join(', ');
446
+ lines.push(` ${fn.module}.${fn.function} \u2192 ${label}`);
447
+ }
448
+
449
+ return lines.join('\n') + '\n';
450
+ }
451
+
452
+ /** Check a report against a deny list. Returns list of violations (empty = pass). */
453
+ export function checkPolicy(
454
+ report: BallCapabilityReport,
455
+ deny: ReadonlySet<string>,
456
+ ): string[] {
457
+ const violations: string[] = [];
458
+ for (const entry of report.capabilities) {
459
+ if (!deny.has(entry.capability)) continue;
460
+ for (const site of entry.callSites) {
461
+ violations.push(
462
+ `${entry.capability}: ${site.module}.${site.function} calls ${site.calleeModule}.${site.calleeFunction}`,
463
+ );
464
+ }
465
+ }
466
+ return violations;
467
+ }