formatCapabilityReport function
Format a capability report Map as human-readable text (byte-identical to
the legacy proto-report renderer). Built from a line list joined with \n
plus a trailing newline — reproducing StringBuffer.writeln semantics — so
it self-hosts on the compiled TS/C++/Rust CLIs (which have no StringBuffer).
Implementation
String formatCapabilityReport(Map report) {
final lines = <String>[];
lines.add(
'Ball Capability Audit: ${report['programName']} v${report['programVersion']}',
);
lines.add('============================================================');
lines.add('');
lines.add('Capabilities:');
final List capabilities = report['capabilities'];
for (final entry in capabilities) {
final icon = entry['riskLevel'] == 'none' ? '✓' : '⚠';
final List callSites = entry['callSites'];
final siteCount = callSites.length;
if (siteCount == 0) {
lines.add(' $icon ${entry['capability']} (pure computation)');
} else {
final siteStrs = <String>[];
for (final s in callSites) {
siteStrs.add(
'${s['module']}.${s['function']} → ${s['calleeModule']}.${s['calleeFunction']}',
);
}
final sites = siteStrs.join(', ');
lines.add(
' $icon ${entry['capability']} ($siteCount call sites: $sites)',
);
}
}
final Map s = report['summary'];
final absent = <String>[];
final readsFs = s['readsFilesystem'];
final writesFs = s['writesFilesystem'];
if (readsFs == false && writesFs == false) absent.add('filesystem');
if (s['usesNetwork'] == false) absent.add('network');
if (s['controlsProcess'] == false) absent.add('process');
if (s['usesMemory'] == false) absent.add('memory');
if (s['usesConcurrency'] == false) absent.add('concurrency');
if (s['usesRandom'] == false) absent.add('random');
if (absent.isNotEmpty) {
lines.add(' ✗ NONE: ${absent.join(', ')}');
}
// Base-function shadows (issue #420): a bare-name call to a shadowed name
// dispatches to the user function, so the shadowed base capability is not
// itself exercised — but the collision is a review signal (a decoy can hide a
// capability's name), so it is surfaced explicitly and never as a bare "no
// risk". `shadows` is always populated by the analyzer; guard defensively for
// any hand-built report Map (mirrors the `containsKey` idiom used for
// `capSites` above — engine-safe, no `??`).
final List shadows = report.containsKey('shadows')
? report['shadows']
: <Object?>[];
final hasShadows = shadows.isNotEmpty;
if (hasShadows) {
lines.add('');
lines.add('Shadowed base functions:');
for (final sh in shadows) {
lines.add(
' ⚠ ${sh['module']}.${sh['function']} shadows '
'${sh['baseModule']}.${sh['function']} '
'(${sh['capability']}, ${sh['riskLevel']} risk)',
);
}
}
lines.add('');
final isPure = s['isPure'] == true;
final controlsProcess = s['controlsProcess'] == true;
final usesMemory = s['usesMemory'] == true;
final usesNetwork = s['usesNetwork'] == true;
final rFs = s['readsFilesystem'] == true;
final wFs = s['writesFilesystem'] == true;
final usesConcurrency = s['usesConcurrency'] == true;
String risk;
if (isPure) {
// A program that only computes yet declares a base-function shadow is not
// cleanly "no risk" — the bare "pure computation only" line would be
// misleading, so escalate it to a review prompt (the shadow section above
// names the offending function and its capability).
risk = hasShadows
? 'REVIEW REQUIRED — declares base-function shadows'
: 'NO RISK — pure computation only';
} else if (controlsProcess || usesMemory || usesNetwork) {
risk = 'HIGH RISK';
} else if (rFs || wFs || usesConcurrency) {
risk = 'MEDIUM RISK';
} else {
risk = 'LOW RISK';
}
lines.add('Summary: $risk');
lines.add(
' ${s['totalFunctions']} functions: ${s['pureFunctions']} pure, ${s['effectfulFunctions']} effectful',
);
lines.add('');
lines.add('Per-function breakdown:');
final List functions = report['functions'];
for (final fn in functions) {
final List fnCaps = fn['capabilities'];
final nonPure = <String>[];
for (final c in fnCaps) {
if (c != 'pure') nonPure.add(c);
}
final label = nonPure.isEmpty ? 'pure' : nonPure.join(', ');
lines.add(' ${fn['module']}.${fn['function']} → $label');
}
return '${lines.join('\n')}\n';
}