main gained six PRs (#1-#6) while the audit fixes and the state-map seam were in progress. Four of main's commits fixed the same bugs this branch did, independently; where they overlap, the more complete version wins: - Fingerprints: main's (a0844bb) - catchSite (`member#index` among all catches), union/owner as identity, a legacy fingerprint so version-1 baselines still match, baseline "version": 2. This branch's `catch` ordinal is dropped; its fingerprint_test still guards the result. - verify --rule: main's verifyAnalysis (cf9e0b7), which also reports findings in skipped files as NOT SCANNED. - Unreadable SDK: both kept - this branch's up-front probe gives the early, specific error; main's catch around the analyzer (d6b6bcb) is the backstop. - Riverpod: main's "a ref.listen is not a render" (f6411dc) and this branch's AsyncError patterns / stored-value tracking compose; the fixture keeps both sets of cases. Ported onto the model-only rules (the seam), since main edited code this branch had moved into front-end facts: - excerpt.dart's literal-free, bounded quoting (a prompt-injection guard) now also covers front-end facts: SwallowedFailure.handler and NonSubscribingRead.excerpt, and the C++ front end elides string literals in handler evidence the same way. - ScanCoverage: runChecks stays source-free; the model carries coverage (`scanCoverage` in the state JSON, written by both front ends), so `check --model` states coverage like a source check. - main's softened wording (rendered-not-emitted "probably unreachable", the remove-policy dead-handler text, notifier-field-unread) and the backticked flag message. Docs: main's README grew the Firestore query inventory and the output contract; the README stays short, the inventory moves to docs/queries.md, and the contract, exit codes, verify and baseline details join docs/usage.md. Checked against origin/main on resolved mirrors of the dogfood apps: nuwayze (102), dex (0) and word_alive (0) identical; tick differs only by the intended riverpod stored-value fix. On the fixture every difference is one of this branch's fixes. 214 tests pass.
216 lines
7.3 KiB
Dart
216 lines
7.3 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:pipe/pipe.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
const String fixturePath = 'test/fixtures/stuck_loading';
|
|
|
|
void main() {
|
|
late ProjectAnalysis analysis;
|
|
|
|
setUpAll(() async {
|
|
final project = await loadProject(fixturePath);
|
|
analysis = await analyzeProject(project, concurrency: 1);
|
|
});
|
|
|
|
tearDownAll(() async {
|
|
await analysis.dispose();
|
|
});
|
|
|
|
FindingReport reportWith(Set<String>? baseline) =>
|
|
runChecks(flow: analysis.flow, baseline: baseline);
|
|
|
|
test('encodes a baseline whose fingerprints cover every finding', () {
|
|
final encoded = encodeBaseline(
|
|
analysis.project.root,
|
|
analysis.report.findings,
|
|
);
|
|
final decoded = decodeBaseline(encoded);
|
|
expect(
|
|
decoded,
|
|
hasLength(analysis.report.findings.length),
|
|
reason: 'fingerprints should be unique per finding',
|
|
);
|
|
expect(encoded, contains('"version": 2'));
|
|
});
|
|
|
|
test('a baseline turns every existing finding into an accepted one', () {
|
|
final decoded = decodeBaseline(
|
|
encodeBaseline(analysis.project.root, analysis.report.findings),
|
|
);
|
|
final baselined = reportWith(decoded);
|
|
|
|
expect(baselined.newFindings, isEmpty);
|
|
expect(
|
|
baselined.baselinedFindings,
|
|
hasLength(analysis.report.findings.length),
|
|
);
|
|
// Severity gates must only look at NEW findings, or CI would stay red.
|
|
expect(
|
|
baselined.countBySeverity.values.every((count) => count == 0),
|
|
isTrue,
|
|
);
|
|
expect(baselined.countByRule, isEmpty);
|
|
expect(
|
|
baselined.toJson()['totals'],
|
|
containsPair('baselined', analysis.report.findings.length),
|
|
);
|
|
});
|
|
|
|
test('without a baseline nothing is marked as accepted', () {
|
|
final fresh = reportWith(null);
|
|
expect(fresh.baselinedFindings, isEmpty);
|
|
expect(fresh.newFindings, hasLength(analysis.report.findings.length));
|
|
});
|
|
|
|
test('no fingerprint depends on the line its finding sits on', () {
|
|
// Shifting only findings.first proved nothing about the other rules: three
|
|
// of them fell back to `line=` and loading-not-reset embedded the catch's
|
|
// absolute line, so moving code broke their baseline entries.
|
|
expect(analysis.report.findings, isNotEmpty);
|
|
for (final finding in analysis.report.findings) {
|
|
final catchLine = finding.data['catchLine'];
|
|
final moved = Finding(
|
|
rule: finding.rule,
|
|
severity: finding.severity,
|
|
confidence: finding.confidence,
|
|
message: finding.message,
|
|
file: finding.file,
|
|
line: (finding.line ?? 1) + 500,
|
|
evidence: finding.evidence,
|
|
remediation: finding.remediation,
|
|
// Line-valued data moves with the code.
|
|
data: {
|
|
...finding.data,
|
|
if (catchLine is int) 'catchLine': catchLine + 500,
|
|
},
|
|
);
|
|
expect(
|
|
moved.fingerprint,
|
|
finding.fingerprint,
|
|
reason: '${finding.rule} at ${finding.location}',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('inserting lines above every finding keeps every identity', () async {
|
|
// The end-to-end version of the test above: really move the code, re-run
|
|
// the analysis, and compare. Positional data derived from the AST (such as
|
|
// a catch's place in its method) is exercised here, not simulated.
|
|
final temp = Directory.systemTemp.createTempSync('pipe-shifted');
|
|
_copyTree(Directory(fixturePath), temp);
|
|
for (final entity in Directory('${temp.path}/lib').listSync()) {
|
|
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
|
entity.writeAsStringSync('// shifted\n' * 7 + entity.readAsStringSync());
|
|
}
|
|
final shifted = await analyzeProject(
|
|
await loadProject(temp.path),
|
|
concurrency: 1,
|
|
);
|
|
try {
|
|
final lines = {
|
|
for (final finding in analysis.report.findings)
|
|
if (finding.line != null) finding.line!,
|
|
};
|
|
final shiftedLines = {
|
|
for (final finding in shifted.report.findings)
|
|
if (finding.line != null) finding.line!,
|
|
};
|
|
expect(shiftedLines, isNot(lines), reason: 'the code must have moved');
|
|
expect(
|
|
{for (final finding in shifted.report.findings) finding.fingerprint},
|
|
{for (final finding in analysis.report.findings) finding.fingerprint},
|
|
);
|
|
} finally {
|
|
await shifted.dispose();
|
|
}
|
|
});
|
|
|
|
test('a version-1 baseline still accepts the findings it recorded', () {
|
|
// Fingerprints changed for rules that used to embed a line; an older
|
|
// baseline must keep suppressing them rather than report them all as new.
|
|
final legacy = {
|
|
for (final finding in analysis.report.findings) finding.legacyFingerprint,
|
|
};
|
|
final baselined = reportWith(legacy);
|
|
expect(baselined.newFindings, isEmpty);
|
|
|
|
final entries = [
|
|
for (final fingerprint in legacy)
|
|
BaselineEntry(
|
|
fingerprint: fingerprint,
|
|
rule: fingerprint.split('|').first,
|
|
file: fingerprint.split('|')[1],
|
|
label: fingerprint,
|
|
),
|
|
];
|
|
final verification = verifyAgainstBaseline(
|
|
report: analysis.report,
|
|
baseline: entries,
|
|
);
|
|
expect(verification.resolved, isEmpty);
|
|
expect(verification.newFindings, isEmpty);
|
|
});
|
|
|
|
test('a member name is a semantic key, so moving that code keeps identity', () {
|
|
// notifier-never-notifies carries only {owner, member}; before `member` was
|
|
// treated as discriminating, its fingerprint embedded the line number and
|
|
// any code movement broke the baseline entry.
|
|
final finding = analysis.report.findings.firstWhere(
|
|
(f) => f.rule == ruleNotifierNeverNotifies,
|
|
);
|
|
expect(finding.data.containsKey('member'), isTrue);
|
|
final moved = Finding(
|
|
rule: finding.rule,
|
|
severity: finding.severity,
|
|
confidence: finding.confidence,
|
|
message: finding.message,
|
|
file: finding.file,
|
|
line: (finding.line ?? 1) + 900,
|
|
evidence: finding.evidence,
|
|
remediation: finding.remediation,
|
|
data: finding.data,
|
|
);
|
|
expect(moved.fingerprint, finding.fingerprint);
|
|
});
|
|
|
|
test('two findings in one method still get distinct fingerprints', () {
|
|
// A method can hold more than one swallowed catch, so this rule carries the
|
|
// catch's index within its member: position enough to tell the two apart,
|
|
// without the absolute line that broke identity whenever code moved.
|
|
final catches = analysis.report.findings
|
|
.where(
|
|
(f) =>
|
|
f.rule == ruleLoadingNotReset &&
|
|
f.data['member'] == 'twoSwallowedAwaits',
|
|
)
|
|
.toList();
|
|
expect(catches, hasLength(2));
|
|
expect(
|
|
catches.map((f) => f.fingerprint).toSet(),
|
|
hasLength(2),
|
|
reason: 'a baseline must be able to tell the two catches apart',
|
|
);
|
|
});
|
|
|
|
test('rejects a malformed baseline with an actionable error', () {
|
|
expect(() => decodeBaseline('not json'), throwsA(isA<PipeException>()));
|
|
expect(
|
|
() => decodeBaseline('{"version":1}'),
|
|
throwsA(isA<PipeException>()),
|
|
);
|
|
});
|
|
}
|
|
|
|
void _copyTree(Directory from, Directory to) {
|
|
to.createSync(recursive: true);
|
|
for (final entity in from.listSync(recursive: true)) {
|
|
final relative = entity.path.substring(from.path.length + 1);
|
|
final target = '${to.path}/$relative';
|
|
if (entity is Directory) {
|
|
Directory(target).createSync(recursive: true);
|
|
} else if (entity is File) {
|
|
File(target).writeAsBytesSync(entity.readAsBytesSync());
|
|
}
|
|
}
|
|
}
|