pipe/test/baseline_test.dart
trvon fb055c9978
Some checks failed
Lint Commits / commitlint (pull_request) Successful in 19s
Tests / Static Analysis (pull_request) Has been cancelled
Tests / Tests (pull_request) Has been cancelled
merge: bring main into the audit and state-map work
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.
2026-09-24 07:33:53 -06:00

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());
}
}
}