canary-test-cli 6.5.0 → 6.7.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.
- package/bin/canary.js +69 -1
- package/dist/doctor-manifest.js +6 -1
- package/dist/doctor.js +7 -4
- package/dist/engine/cli-commands.js +84 -25
- package/dist/engine/cli.core.js +1 -1
- package/dist/engine/core/framework-probes.js +218 -0
- package/dist/engine/core/framework-registry.js +4 -1
- package/dist/engine/core/fs-glob.js +185 -0
- package/dist/engine/core/gate-result.js +27 -4
- package/dist/engine/core/migrator.js +240 -289
- package/dist/engine/core/scaffold-templates.js +123 -0
- package/dist/engine/core/scaffolder.js +4 -105
- package/dist/engine/core/static-linter.js +169 -17
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +34 -30
- package/dist/engine/guardian/analysis-emit.js +13 -4
- package/dist/engine/guardian/cli.js +113 -16
- package/dist/engine/guardian/coverage.js +291 -9
- package/dist/engine/guardian/github-paging.js +97 -0
- package/dist/engine/guardian/pr-check.js +285 -26
- package/dist/engine/guardian/pr-comment.js +29 -15
- package/dist/engine/history/async-store.js +20 -0
- package/dist/gate-result.js +27 -4
- package/dist/overlay-commands.js +31 -3
- package/package.json +2 -2
|
@@ -53,12 +53,12 @@ import { AuthoringContext, InSessionAgentProbe, InSessionAgentTier, decideBlock,
|
|
|
53
53
|
import { RestReactionsClient, collectAdjudications, loadAdjudicationRecords, renderPrecision, summarizePrecision, } from './adjudication.js';
|
|
54
54
|
import { emitAnalysis } from './analysis-emit.js';
|
|
55
55
|
import { gateOutcome } from '../core/gate-result.js';
|
|
56
|
-
import { resolveCoverage, validateCoverageJson, } from './coverage.js';
|
|
56
|
+
import { coverageDegradedNotice, resolveCoverage, resolveCoverageWithInput, validateCoverageJson, } from './coverage.js';
|
|
57
57
|
import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
|
|
58
58
|
import { extractApiDiff } from './diff-extractor.js';
|
|
59
59
|
import { HardGateAbstained, HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
|
|
60
60
|
import { mapImpact } from './impact-mapper.js';
|
|
61
|
-
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
|
|
61
|
+
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
|
|
62
62
|
import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
|
|
63
63
|
import { buildSummary } from './summary-emitter.js';
|
|
64
64
|
import { resolveTier } from './tier.js';
|
|
@@ -202,6 +202,53 @@ export function prContextFromEnv(env) {
|
|
|
202
202
|
}
|
|
203
203
|
return null;
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* The `https://github.com/<owner>/<repo>/blob/<sha>` prefix for comment
|
|
207
|
+
* permalinks, or `null` when it cannot be resolved.
|
|
208
|
+
*
|
|
209
|
+
* Prefers `pull_request.head.sha` from the event payload over `GITHUB_SHA`: on
|
|
210
|
+
* a `pull_request` event `GITHUB_SHA` is the ephemeral merge commit, and a blob
|
|
211
|
+
* URL against it can 404 once the ref is gone. The head SHA is a real commit on
|
|
212
|
+
* the contributor's branch and stays resolvable.
|
|
213
|
+
*
|
|
214
|
+
* Returns `null` rather than a partial URL whenever repo or SHA is missing, so
|
|
215
|
+
* {@link render} falls back to plain code text. That degradation is deliberate:
|
|
216
|
+
* an unresolvable link still *looks* clickable, which is worse than no link.
|
|
217
|
+
*/
|
|
218
|
+
export function blobBaseFromEnv(env) {
|
|
219
|
+
const repo = env['GITHUB_REPOSITORY'];
|
|
220
|
+
if (!repo || !repo.includes('/'))
|
|
221
|
+
return null;
|
|
222
|
+
const sha = headShaFromEvent(env['GITHUB_EVENT_PATH']) ?? env['GITHUB_SHA'];
|
|
223
|
+
if (!sha)
|
|
224
|
+
return null;
|
|
225
|
+
return `https://github.com/${repo}/blob/${sha}`;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `pull_request.head.sha` from the event payload at `eventPath`, or `null`.
|
|
229
|
+
*
|
|
230
|
+
* Every failure — no path, unreadable file, non-JSON body, a payload without a
|
|
231
|
+
* `pull_request` — returns `null` so {@link blobBaseFromEnv} falls through to
|
|
232
|
+
* `GITHUB_SHA` rather than dropping links entirely: a merge-commit link still
|
|
233
|
+
* beats no link.
|
|
234
|
+
*/
|
|
235
|
+
function headShaFromEvent(eventPath) {
|
|
236
|
+
if (!eventPath)
|
|
237
|
+
return null;
|
|
238
|
+
try {
|
|
239
|
+
// Optional chaining carries the shape check: on a payload that is not an
|
|
240
|
+
// object (a bare number or string), `?.` short-circuits to `undefined`
|
|
241
|
+
// exactly as an explicit `typeof === 'object'` guard would.
|
|
242
|
+
const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
|
|
243
|
+
const head = event?.pull_request?.head?.sha;
|
|
244
|
+
if (typeof head !== 'string')
|
|
245
|
+
return null;
|
|
246
|
+
return head || null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
205
252
|
/**
|
|
206
253
|
* At-desk fork signal (guard b), FAIL-CLOSED on ambiguity.
|
|
207
254
|
*
|
|
@@ -787,8 +834,8 @@ function precisionCmd(opts, deps) {
|
|
|
787
834
|
* PR context is resolvable from env, prints the body instead of crashing; a
|
|
788
835
|
* read-only-token degradation is surfaced LOUDLY.
|
|
789
836
|
*/
|
|
790
|
-
async function postStickyComment(findings, resolution, deps) {
|
|
791
|
-
const body = render(findings, 'comment', resolution.effective, resolution.degraded_notice);
|
|
837
|
+
async function postStickyComment(findings, resolution, deps, gateMeta = null) {
|
|
838
|
+
const body = render(findings, 'comment', resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env));
|
|
792
839
|
const ctx = prContextFromEnv(deps.env);
|
|
793
840
|
if (ctx === null) {
|
|
794
841
|
deps.out(`guardian: no PR context in env ${EM_DASH} printing instead.`);
|
|
@@ -806,10 +853,17 @@ async function postStickyComment(findings, resolution, deps) {
|
|
|
806
853
|
// D7: every filtered path stays visible as a SkipEntry, never folded
|
|
807
854
|
// into "passed". One entry per path so the rendered count still equals
|
|
808
855
|
// the path count the old `N path(s) skipped` line reported.
|
|
809
|
-
function prCheckSkipEntries(skipped, testUnits, barrelUnits, noisePaths = []) {
|
|
856
|
+
function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [], noisePaths = [], typeOnlyUnits = []) {
|
|
810
857
|
return [
|
|
811
858
|
...skipped.map((u) => ({ name: u.path, reason: 'skipGlobs' })),
|
|
812
859
|
...testUnits.map((u) => ({ name: u.path, reason: 'test path' })),
|
|
860
|
+
// #565: distinct from 'test path' on purpose -- these are test
|
|
861
|
+
// infrastructure recognised by filename idiom, and adjudication needs to
|
|
862
|
+
// tell the two suppression causes apart.
|
|
863
|
+
...supportUnits.map((u) => ({ name: u.path, reason: 'test support' })),
|
|
864
|
+
// #562: likewise distinct -- adjudication has to be able to measure this
|
|
865
|
+
// class separately, since it is the one that held precision at 13/20.
|
|
866
|
+
...typeOnlyUnits.map((u) => ({ name: u.path, reason: 'type-only module' })),
|
|
813
867
|
...barrelUnits.map((u) => ({
|
|
814
868
|
name: u.path,
|
|
815
869
|
reason: 're-export barrel',
|
|
@@ -839,7 +893,12 @@ function abstainPrCheck(skipped, format, deps) {
|
|
|
839
893
|
for (const line of PR_CHECK_ABSTAIN_REMEDIATION)
|
|
840
894
|
deps.out(line);
|
|
841
895
|
if (format === 'json') {
|
|
842
|
-
deps.out(ensureAscii(JSON.stringify(
|
|
896
|
+
deps.out(ensureAscii(JSON.stringify(
|
|
897
|
+
// #579: `skipped` carries the denominator the abstention collapsed
|
|
898
|
+
// to. Without it a consumer sees `abstained: true` and cannot tell
|
|
899
|
+
// WHAT was dropped or why -- the #508 class one layer down, on the
|
|
900
|
+
// only surface a machine can read.
|
|
901
|
+
{ findings: [], tier: 0, checked: 0, abstained: true, skipped }, null, 2)));
|
|
843
902
|
}
|
|
844
903
|
throw new CliExit(outcome.exitCode); // EXIT_ABSTAINED
|
|
845
904
|
}
|
|
@@ -923,19 +982,31 @@ async function prCheckCmd(opts, deps) {
|
|
|
923
982
|
const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
|
|
924
983
|
// FIX A: drop test-path units -- a test does not itself need a test.
|
|
925
984
|
const [keptTest, testUnits] = filterTestUnits(keptSkip);
|
|
985
|
+
// #565: drop test *support* units -- a conftest / fixture module is the
|
|
986
|
+
// harness the tests run inside, so no test can cover it at any tier.
|
|
987
|
+
const [keptSupport, supportUnits] = filterTestSupportUnits(keptTest);
|
|
988
|
+
// #562: drop modules with no runtime content -- an interface cannot be
|
|
989
|
+
// executed, so its uncovered lines are a finding no test could ever satisfy.
|
|
990
|
+
// `.` (cwd), matching the repoRoot convention `resolveCoverageWithInput`
|
|
991
|
+
// already uses for the heuristic tier's own file reads.
|
|
992
|
+
const [keptTyped, typeOnlyUnits] = filterTypeOnlyUnits(keptSupport, '.');
|
|
926
993
|
// FIX 2: drop pure re-export/barrel files.
|
|
927
994
|
const reexportPaths = findReexportOnly(diffText);
|
|
928
|
-
const barrelUnits =
|
|
929
|
-
const kept =
|
|
995
|
+
const barrelUnits = keptTyped.filter((u) => reexportPaths.has(u.path));
|
|
996
|
+
const kept = keptTyped.filter((u) => !reexportPaths.has(u.path));
|
|
930
997
|
// Advisory weak-test findings for added tests that assert nothing.
|
|
931
998
|
const weakFindings = config.weak_tests
|
|
932
999
|
? buildWeakTestFindings(testUnits, diffText)
|
|
933
1000
|
: [];
|
|
934
|
-
const preFilterSkipped = skipped.length +
|
|
1001
|
+
const preFilterSkipped = skipped.length +
|
|
1002
|
+
testUnits.length +
|
|
1003
|
+
barrelUnits.length +
|
|
1004
|
+
supportUnits.length +
|
|
1005
|
+
typeOnlyUnits.length;
|
|
935
1006
|
if (kept.length === 0 && weakFindings.length === 0) {
|
|
936
|
-
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits), opts.format, deps);
|
|
1007
|
+
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, [], typeOnlyUnits), opts.format, deps);
|
|
937
1008
|
}
|
|
938
|
-
const results =
|
|
1009
|
+
const { results, coverage } = resolveCoverageWithInput(kept, {
|
|
939
1010
|
coveragePath: opts.coverage ?? null,
|
|
940
1011
|
// #320: under a hard gate the graph tier requires a DIRECT test->source edge
|
|
941
1012
|
// (depth 1); soft stays unbounded. An explicit config value wins.
|
|
@@ -953,7 +1024,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
953
1024
|
// SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
|
|
954
1025
|
// must be able to tell "nothing was judgeable" from "everything passed".
|
|
955
1026
|
if (scoredResults.length === 0 && findings.length === 0) {
|
|
956
|
-
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, noiseResults.map((r) => r.unit.path)), opts.format, deps);
|
|
1027
|
+
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, noiseResults.map((r) => r.unit.path), typeOnlyUnits), opts.format, deps);
|
|
957
1028
|
}
|
|
958
1029
|
// SC-5 (PR half): resolve the requested tier against actual capability. No
|
|
959
1030
|
// agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
|
|
@@ -966,6 +1037,22 @@ async function prCheckCmd(opts, deps) {
|
|
|
966
1037
|
// Compute the gate result once, up front: the emitted record carries it and it
|
|
967
1038
|
// is the process exit at the end (SC-4 -- emit never changes the exit logic).
|
|
968
1039
|
const exitCode = computeExitCode(findings, effectiveGate);
|
|
1040
|
+
// #554: every surface below carries the coverage-input state, so a run that
|
|
1041
|
+
// never saw a coverage report cannot present as one that checked and passed.
|
|
1042
|
+
const gateMeta = {
|
|
1043
|
+
checked: scoredResults.length,
|
|
1044
|
+
abstained: false,
|
|
1045
|
+
coverage,
|
|
1046
|
+
};
|
|
1047
|
+
const coverageNotice = coverageDegradedNotice(coverage);
|
|
1048
|
+
if (coverageNotice) {
|
|
1049
|
+
// `--format json` owns stdout: a `::warning::` line there would make the
|
|
1050
|
+
// document unparseable, so the annotation goes to stderr on that path. Both
|
|
1051
|
+
// streams are scanned for workflow commands, so CI still sees it.
|
|
1052
|
+
const machineStdout = !opts.postComment && !opts.emitAnalysis && opts.format === 'json';
|
|
1053
|
+
(machineStdout ? deps.err : deps.out)(degradationAnnotation(coverageNotice));
|
|
1054
|
+
appendStepSummary(deps.env, coverageNotice);
|
|
1055
|
+
}
|
|
969
1056
|
let commentPosted = false;
|
|
970
1057
|
if (opts.emitAnalysis) {
|
|
971
1058
|
// SC-10 producer half: write ONE record to the analyses channel. On an
|
|
@@ -981,6 +1068,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
981
1068
|
exit_code: exitCode,
|
|
982
1069
|
checked: scoredResults.length,
|
|
983
1070
|
abstained: false, // an abstained run exits before emit (see plan)
|
|
1071
|
+
coverage,
|
|
984
1072
|
});
|
|
985
1073
|
if (res.action === 'emitted') {
|
|
986
1074
|
deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
|
|
@@ -991,18 +1079,18 @@ async function prCheckCmd(opts, deps) {
|
|
|
991
1079
|
deps.out(degradationAnnotation(res.notice));
|
|
992
1080
|
appendStepSummary(deps.env, res.notice);
|
|
993
1081
|
deps.err(res.notice);
|
|
994
|
-
await postStickyComment(findings, resolution, deps);
|
|
1082
|
+
await postStickyComment(findings, resolution, deps, gateMeta);
|
|
995
1083
|
commentPosted = true;
|
|
996
1084
|
}
|
|
997
1085
|
}
|
|
998
1086
|
if (opts.postComment && !commentPosted) {
|
|
999
1087
|
// Explicit `--post-comment`: post/upsert unless the SC-10 fallback already
|
|
1000
1088
|
// posted this run.
|
|
1001
|
-
await postStickyComment(findings, resolution, deps);
|
|
1089
|
+
await postStickyComment(findings, resolution, deps, gateMeta);
|
|
1002
1090
|
}
|
|
1003
1091
|
else if (!opts.emitAnalysis && !opts.postComment) {
|
|
1004
1092
|
// Local, non-posting default: render to stdout in `--format`.
|
|
1005
|
-
deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice,
|
|
1093
|
+
deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env)));
|
|
1006
1094
|
}
|
|
1007
1095
|
throw new CliExit(exitCode);
|
|
1008
1096
|
}
|
|
@@ -1016,8 +1104,17 @@ function buildGaps(diffText, config, coveragePath, graphMaxDepth) {
|
|
|
1016
1104
|
const units = scopeDiff(diffText);
|
|
1017
1105
|
const [keptSkip] = filterSkipped(units, config.skip_globs);
|
|
1018
1106
|
const [keptTest] = filterTestUnits(keptSkip);
|
|
1107
|
+
// #565: never hand the authoring tier a conftest/fixture module either -- a
|
|
1108
|
+
// generated "test for the fixture" is exactly the inversion the gate exists
|
|
1109
|
+
// to prevent, except it also writes a file (measured: this surface proposed
|
|
1110
|
+
// `scripts/otel_bootstrap/test_conftest_otel.py`).
|
|
1111
|
+
const [keptSupport] = filterTestSupportUnits(keptTest);
|
|
1112
|
+
// #562: nor a type-only module -- measured, this surface proposed writing
|
|
1113
|
+
// `src/ConfirmModal/types.test.ts` to test an interface, a file whose only
|
|
1114
|
+
// possible content is an assertion about nothing.
|
|
1115
|
+
const [keptTyped] = filterTypeOnlyUnits(keptSupport, '.');
|
|
1019
1116
|
const reexportPaths = findReexportOnly(diffText);
|
|
1020
|
-
const kept =
|
|
1117
|
+
const kept = keptTyped.filter((u) => !reexportPaths.has(u.path));
|
|
1021
1118
|
if (kept.length === 0)
|
|
1022
1119
|
return [];
|
|
1023
1120
|
const results = resolveCoverage(kept, { coveragePath, graphMaxDepth });
|
|
@@ -594,8 +594,6 @@ function attrValue(attrs, name) {
|
|
|
594
594
|
/** Read a report file as UTF-8, returning `null` on any read/decode failure. */
|
|
595
595
|
function readReportText(reportPath) {
|
|
596
596
|
try {
|
|
597
|
-
if (!existsSync(reportPath))
|
|
598
|
-
return null;
|
|
599
597
|
const buf = readFileSync(reportPath);
|
|
600
598
|
// Fatal decode: a non-UTF-8 report must fall through, never raise out of
|
|
601
599
|
// the guardian gate (mirrors Python's UnicodeDecodeError → None).
|
|
@@ -614,9 +612,19 @@ function readReportText(reportPath) {
|
|
|
614
612
|
* blocks).
|
|
615
613
|
*/
|
|
616
614
|
export function resolveFromReport(units, reportPath) {
|
|
615
|
+
const { index } = readReportIndex(reportPath);
|
|
616
|
+
if (index === null)
|
|
617
|
+
return null;
|
|
618
|
+
return matchUnitsToIndex(units, index);
|
|
619
|
+
}
|
|
620
|
+
/** Read + parse a coverage report, reporting each step's outcome separately. */
|
|
621
|
+
function readReportIndex(reportPath) {
|
|
622
|
+
if (!existsSync(reportPath))
|
|
623
|
+
return { found: false, index: null };
|
|
617
624
|
const text = readReportText(reportPath);
|
|
625
|
+
// Present but unreadable/non-UTF-8 counts as found-and-unusable, not absent.
|
|
618
626
|
if (text === null)
|
|
619
|
-
return null;
|
|
627
|
+
return { found: true, index: null };
|
|
620
628
|
const name = basename(reportPath).toLowerCase();
|
|
621
629
|
let index;
|
|
622
630
|
if (name.endsWith('.json')) {
|
|
@@ -625,7 +633,7 @@ export function resolveFromReport(units, reportPath) {
|
|
|
625
633
|
parsed = JSON.parse(text);
|
|
626
634
|
}
|
|
627
635
|
catch {
|
|
628
|
-
return null;
|
|
636
|
+
return { found: true, index: null };
|
|
629
637
|
}
|
|
630
638
|
index = parseCoverageJson(parsed);
|
|
631
639
|
}
|
|
@@ -637,10 +645,15 @@ export function resolveFromReport(units, reportPath) {
|
|
|
637
645
|
}
|
|
638
646
|
else {
|
|
639
647
|
// Unrecognized format → fall through to a lower fidelity tier.
|
|
640
|
-
return null;
|
|
648
|
+
return { found: true, index: null };
|
|
641
649
|
}
|
|
642
|
-
if (index === null || Object.keys(index).length === 0)
|
|
643
|
-
return null;
|
|
650
|
+
if (index === null || Object.keys(index).length === 0) {
|
|
651
|
+
return { found: true, index: null };
|
|
652
|
+
}
|
|
653
|
+
return { found: true, index };
|
|
654
|
+
}
|
|
655
|
+
/** Resolve every unit the report index can speak to (COVERAGE_VERIFIED). */
|
|
656
|
+
function matchUnitsToIndex(units, index) {
|
|
644
657
|
const results = [];
|
|
645
658
|
for (const unit of units) {
|
|
646
659
|
const hits = matchHits(unit.path, index);
|
|
@@ -682,6 +695,207 @@ const TEST_PATH_RE = /(^|\/)tests?\/|(^|\/)test_[^/]*\.py$|\.test\.[^/]+$|\.spec
|
|
|
682
695
|
export function isTestPath(path) {
|
|
683
696
|
return TEST_PATH_RE.test(path);
|
|
684
697
|
}
|
|
698
|
+
/**
|
|
699
|
+
* Split a basename's stem into `-`/`_`/`.`-separated components (#565).
|
|
700
|
+
*
|
|
701
|
+
* `playwright-fixture.ts` → `['playwright', 'fixture']`;
|
|
702
|
+
* `user.fixtures.ts` → `['user', 'fixtures']`. The final extension is dropped
|
|
703
|
+
* first so it never appears as a component.
|
|
704
|
+
*/
|
|
705
|
+
function basenameComponents(path) {
|
|
706
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
707
|
+
const dot = base.lastIndexOf('.');
|
|
708
|
+
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
709
|
+
return stem.split(/[-_.]/).filter((part) => part.length > 0);
|
|
710
|
+
}
|
|
711
|
+
/** Basename components that mark a file as test *support* rather than source. */
|
|
712
|
+
const FIXTURE_COMPONENTS = new Set([
|
|
713
|
+
'fixture',
|
|
714
|
+
'fixtures',
|
|
715
|
+
]);
|
|
716
|
+
/**
|
|
717
|
+
* True if `path` is test *infrastructure* identified by filename idiom (#565).
|
|
718
|
+
*
|
|
719
|
+
* {@link isTestPath} recognises tests by directory (`tests/**`) or by the
|
|
720
|
+
* `*.test.*` / `*.spec.*` / `test_*.py` naming rules, and the skip layer
|
|
721
|
+
* recognises fixtures by the `fixtures/` *directory* convention. Neither
|
|
722
|
+
* catches a file that is test support by **name** while sitting in an ordinary
|
|
723
|
+
* source directory — the measured cases being a pytest `conftest_otel.py` and a
|
|
724
|
+
* Playwright `playwright-fixture.ts`, both under `scripts/otel_bootstrap/`.
|
|
725
|
+
*
|
|
726
|
+
* Such a file cannot host a test in the sense a coverage finding means: it *is*
|
|
727
|
+
* the harness the tests run inside. Asking it for a covering test inverts the
|
|
728
|
+
* relationship, so a reviewer's only correct response is 👎 — the precision
|
|
729
|
+
* cost #413 and #562 both describe.
|
|
730
|
+
*
|
|
731
|
+
* Two deliberate narrowings:
|
|
732
|
+
*
|
|
733
|
+
* - **Components, not substrings.** `conftestimonial.py` and
|
|
734
|
+
* `prefixtures.ts` are ordinary source and must survive. Matching on
|
|
735
|
+
* `-`/`_`/`.`-separated components is what pytest's own name-based
|
|
736
|
+
* resolution and the `*.fixtures.ts` idiom actually mean.
|
|
737
|
+
* - **`conftest` is Python-only.** It is pytest's resolution rule
|
|
738
|
+
* specifically; a `conftest.js` carries no framework meaning and is left as
|
|
739
|
+
* source.
|
|
740
|
+
*
|
|
741
|
+
* Known over-match, accepted: a production module genuinely named
|
|
742
|
+
* `fixture-generator.ts` is suppressed. That trade is deliberate — a missed
|
|
743
|
+
* finding on a file named after fixtures costs far less than a finding no
|
|
744
|
+
* reviewer can ever act on, which is what drags `precision = TP / (TP + FP)`
|
|
745
|
+
* below the promotion bar.
|
|
746
|
+
*
|
|
747
|
+
* Kept separate from {@link isTestPath} on purpose: that predicate also decides
|
|
748
|
+
* what *confers* graph coverage, so widening it would let a conftest mark every
|
|
749
|
+
* module it imports as tested — a false negative in place of a false positive.
|
|
750
|
+
*/
|
|
751
|
+
export function isTestSupportPath(path) {
|
|
752
|
+
const components = basenameComponents(path);
|
|
753
|
+
if (path.endsWith('.py') && components.includes('conftest'))
|
|
754
|
+
return true;
|
|
755
|
+
return components.some((part) => FIXTURE_COMPONENTS.has(part));
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Filenames/paths that plausibly hold nothing but type declarations (#562).
|
|
759
|
+
*
|
|
760
|
+
* A NAME GATE ONLY -- it decides which files are worth reading, never which
|
|
761
|
+
* are suppressed. {@link isTypeOnlyModule} always confirms against content,
|
|
762
|
+
* because a `types.ts` that also exports an enum or a const map is ordinary
|
|
763
|
+
* TypeScript and its findings are real.
|
|
764
|
+
*/
|
|
765
|
+
function isTypeModuleCandidate(path) {
|
|
766
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
767
|
+
if (base.endsWith('.d.ts'))
|
|
768
|
+
return true;
|
|
769
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(base))
|
|
770
|
+
return false;
|
|
771
|
+
if (base === 'types.ts' || base.endsWith('.types.ts'))
|
|
772
|
+
return true;
|
|
773
|
+
return path.split('/').slice(0, -1).includes('types');
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* True if `path` is a module with no runtime content at all (#562).
|
|
777
|
+
*
|
|
778
|
+
* The false-positive class this closes is the one the heuristic-tier fix
|
|
779
|
+
* (#413) structurally cannot reach. {@link filterHeuristicNoise} is gated on
|
|
780
|
+
* `fidelity === HEURISTIC` on purpose -- a coverage-verified verdict rests on
|
|
781
|
+
* a real lcov row, so suppressing by path at that tier would discard
|
|
782
|
+
* evidence. A type-only module is the case that breaks the symmetry: the lcov
|
|
783
|
+
* row is accurate (39 lines, genuinely never executed) and the finding is
|
|
784
|
+
* still unsatisfiable, because an interface has no runtime existence for a
|
|
785
|
+
* test to reach. The evidence needed is therefore about the FILE, not the
|
|
786
|
+
* tier: prove there is nothing executable in it.
|
|
787
|
+
*
|
|
788
|
+
* Conservative in one direction on purpose. Every uncertainty -- an
|
|
789
|
+
* unreadable file, an unrecognised construct -- resolves to `false`, keeping
|
|
790
|
+
* the finding. A missed suppression costs one noisy finding; a wrong
|
|
791
|
+
* suppression hides untested code, which is the thing the guardian exists to
|
|
792
|
+
* catch.
|
|
793
|
+
*/
|
|
794
|
+
export function isTypeOnlyModule(path, repoRoot) {
|
|
795
|
+
if (!isTypeModuleCandidate(path))
|
|
796
|
+
return false;
|
|
797
|
+
let source;
|
|
798
|
+
try {
|
|
799
|
+
source = readFileSync(join(repoRoot, path), 'utf-8');
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return false; // unreadable -> unproven -> keep the finding
|
|
803
|
+
}
|
|
804
|
+
return isTypeOnlySource(source);
|
|
805
|
+
}
|
|
806
|
+
/** Drop `//` and `/* *\/` comments so keywords inside prose never count. */
|
|
807
|
+
function stripComments(source) {
|
|
808
|
+
const out = [];
|
|
809
|
+
let inBlock = false;
|
|
810
|
+
for (const line of splitLines(source)) {
|
|
811
|
+
let text = line;
|
|
812
|
+
if (inBlock) {
|
|
813
|
+
const end = text.indexOf('*/');
|
|
814
|
+
if (end === -1) {
|
|
815
|
+
out.push('');
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
text = text.slice(end + 2);
|
|
819
|
+
inBlock = false;
|
|
820
|
+
}
|
|
821
|
+
for (;;) {
|
|
822
|
+
const start = text.indexOf('/*');
|
|
823
|
+
if (start === -1)
|
|
824
|
+
break;
|
|
825
|
+
const end = text.indexOf('*/', start + 2);
|
|
826
|
+
if (end === -1) {
|
|
827
|
+
text = text.slice(0, start);
|
|
828
|
+
inBlock = true;
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
text = text.slice(0, start) + text.slice(end + 2);
|
|
832
|
+
}
|
|
833
|
+
const line2 = text.indexOf('//');
|
|
834
|
+
out.push(line2 === -1 ? text : text.slice(0, line2));
|
|
835
|
+
}
|
|
836
|
+
return out.join('\n');
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Top-level constructs TypeScript erases entirely at compile time (#562).
|
|
840
|
+
*
|
|
841
|
+
* An ALLOWLIST, not a denylist, and that is the load-bearing choice. A
|
|
842
|
+
* denylist of runtime keywords misses everything it did not enumerate -- a
|
|
843
|
+
* bare `register('widget')` declares nothing and still runs -- and every gap
|
|
844
|
+
* in it suppresses a real finding. An allowlist fails the other way: an
|
|
845
|
+
* unrecognised construct reads as runtime and the finding survives.
|
|
846
|
+
*
|
|
847
|
+
* Plain `import { X } from '...'` is allowed because TypeScript elides an
|
|
848
|
+
* import whose bindings are only used in type positions; if a binding were
|
|
849
|
+
* used as a value, the using statement itself would appear at top level and
|
|
850
|
+
* be rejected. A side-effect `import './x'` carries no binding, is never
|
|
851
|
+
* elided, and is therefore not matched.
|
|
852
|
+
*/
|
|
853
|
+
function isErasableTopLevelLine(line) {
|
|
854
|
+
if (line === '')
|
|
855
|
+
return true;
|
|
856
|
+
if (/^[})\];,]+$/.test(line))
|
|
857
|
+
return true;
|
|
858
|
+
if (/^import\s+type\b/.test(line))
|
|
859
|
+
return true;
|
|
860
|
+
if (/^import\b.*\sfrom\s/.test(line))
|
|
861
|
+
return true;
|
|
862
|
+
if (/^export\s+type\b/.test(line))
|
|
863
|
+
return true;
|
|
864
|
+
const bare = line.replace(/^(?:export\s+default\s+|export\s+|declare\s+)+/, '');
|
|
865
|
+
return /^(?:interface|type)\s/.test(bare);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* True if every TOP-LEVEL statement in `source` is compile-time-only.
|
|
869
|
+
*
|
|
870
|
+
* Brace depth is tracked so an interface body is never mistaken for
|
|
871
|
+
* statements: only depth-0 lines are judged. An unbalanced file (depth does
|
|
872
|
+
* not return to zero) is treated as unproven rather than type-only -- brace
|
|
873
|
+
* counting is lexical, so a `{` inside a string literal could otherwise hide
|
|
874
|
+
* the rest of the file from inspection.
|
|
875
|
+
*/
|
|
876
|
+
function isTypeOnlySource(source) {
|
|
877
|
+
let depth = 0;
|
|
878
|
+
for (const raw of splitLines(stripComments(source))) {
|
|
879
|
+
const line = raw.trim();
|
|
880
|
+
if (depth === 0 && !isErasableTopLevelLine(line))
|
|
881
|
+
return false;
|
|
882
|
+
depth += bracketDelta(line);
|
|
883
|
+
if (depth < 0)
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
return depth === 0;
|
|
887
|
+
}
|
|
888
|
+
/** Net nesting change across one line: openers minus closers. */
|
|
889
|
+
function bracketDelta(line) {
|
|
890
|
+
let delta = 0;
|
|
891
|
+
for (const ch of line) {
|
|
892
|
+
if (ch === '{' || ch === '(' || ch === '[')
|
|
893
|
+
delta += 1;
|
|
894
|
+
else if (ch === '}' || ch === ')' || ch === ']')
|
|
895
|
+
delta -= 1;
|
|
896
|
+
}
|
|
897
|
+
return delta;
|
|
898
|
+
}
|
|
685
899
|
/**
|
|
686
900
|
* Extensions that denote hand-authored, executable program source (#413).
|
|
687
901
|
*
|
|
@@ -1112,18 +1326,86 @@ function escapeRe(s) {
|
|
|
1112
1326
|
* is forwarded verbatim to {@link resolveFromGraph}.
|
|
1113
1327
|
*/
|
|
1114
1328
|
export function resolveCoverage(units, options = {}) {
|
|
1329
|
+
return resolveCoverageWithInput(units, options).results;
|
|
1330
|
+
}
|
|
1331
|
+
/** Classify a {@link CoverageInputState}. Zero matched is never `verified`. */
|
|
1332
|
+
export function coverageStatus(state) {
|
|
1333
|
+
if (state.unitsTotal > 0 && state.unitsMatched === state.unitsTotal) {
|
|
1334
|
+
return 'verified';
|
|
1335
|
+
}
|
|
1336
|
+
return state.unitsMatched > 0 ? 'partial' : 'unavailable';
|
|
1337
|
+
}
|
|
1338
|
+
const COVERAGE_EM_DASH = '\u{2014}';
|
|
1339
|
+
const FALLBACK_TIER = 'judged at graph/heuristic tier only';
|
|
1340
|
+
/**
|
|
1341
|
+
* The human-readable degradation notice for a coverage run, or `null` when the
|
|
1342
|
+
* report covered every changed unit (nothing was degraded, so nothing is said).
|
|
1343
|
+
*
|
|
1344
|
+
* A run that judged nothing (`unitsTotal === 0`) also returns `null` — it makes
|
|
1345
|
+
* no coverage claim in either direction, and the abstention path reports it.
|
|
1346
|
+
*/
|
|
1347
|
+
export function coverageDegradedNotice(state) {
|
|
1348
|
+
const { requested, found, parsed, filesInReport } = state;
|
|
1349
|
+
const { unitsMatched: matched, unitsTotal: total } = state;
|
|
1350
|
+
if (total === 0)
|
|
1351
|
+
return null;
|
|
1352
|
+
const status = coverageStatus(state);
|
|
1353
|
+
if (status === 'verified')
|
|
1354
|
+
return null;
|
|
1355
|
+
const dash = ` ${COVERAGE_EM_DASH} `;
|
|
1356
|
+
if (status === 'partial') {
|
|
1357
|
+
return (`coverage partial${dash}report at '${requested}' matched ` +
|
|
1358
|
+
`${matched} of ${total} changed file(s); the other ${total - matched} ` +
|
|
1359
|
+
FALLBACK_TIER);
|
|
1360
|
+
}
|
|
1361
|
+
const head = `coverage unavailable${dash}`;
|
|
1362
|
+
if (requested === null) {
|
|
1363
|
+
return `${head}no coverage report was supplied; ${total} changed file(s) ${FALLBACK_TIER}`;
|
|
1364
|
+
}
|
|
1365
|
+
if (!found) {
|
|
1366
|
+
return `${head}report not found at '${requested}'; ${total} changed file(s) ${FALLBACK_TIER}`;
|
|
1367
|
+
}
|
|
1368
|
+
if (!parsed) {
|
|
1369
|
+
return (`${head}report at '${requested}' yielded no usable records; ` +
|
|
1370
|
+
`${total} changed file(s) ${FALLBACK_TIER}`);
|
|
1371
|
+
}
|
|
1372
|
+
return (`${head}report at '${requested}' covers ${filesInReport} file(s) but ` +
|
|
1373
|
+
`matched 0 of ${total} changed file(s); ${FALLBACK_TIER}`);
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* {@link resolveCoverage}, additionally reporting which mode the run was in.
|
|
1377
|
+
*
|
|
1378
|
+
* Identical ladder, identical results — the only addition is the
|
|
1379
|
+
* {@link CoverageInputState} record, so a later reader can tell a clean result
|
|
1380
|
+
* from a blind one (#554).
|
|
1381
|
+
*/
|
|
1382
|
+
export function resolveCoverageWithInput(units, options = {}) {
|
|
1115
1383
|
const { coveragePath = null, graphPath = '.harness/graph/graph.json', repoRoot = '.', graphMaxDepth = null, } = options;
|
|
1116
1384
|
// Reference-keyed map mirrors the Python `id(unit)` bookkeeping, so distinct
|
|
1117
1385
|
// units that happen to share a path are still tracked independently.
|
|
1118
1386
|
const resolved = new Map();
|
|
1119
1387
|
let remaining = [...units];
|
|
1388
|
+
const coverage = {
|
|
1389
|
+
requested: coveragePath,
|
|
1390
|
+
found: false,
|
|
1391
|
+
parsed: false,
|
|
1392
|
+
filesInReport: 0,
|
|
1393
|
+
unitsMatched: 0,
|
|
1394
|
+
unitsTotal: units.length,
|
|
1395
|
+
};
|
|
1120
1396
|
if (coveragePath !== null) {
|
|
1121
|
-
const
|
|
1397
|
+
const read = readReportIndex(coveragePath);
|
|
1398
|
+
coverage.found = read.found;
|
|
1399
|
+
coverage.parsed = read.index !== null;
|
|
1400
|
+
coverage.filesInReport =
|
|
1401
|
+
read.index === null ? 0 : Object.keys(read.index).length;
|
|
1402
|
+
const report = read.index === null ? null : matchUnitsToIndex(remaining, read.index);
|
|
1122
1403
|
// An empty array (no unit matched the report) is falsy-equivalent in the
|
|
1123
1404
|
// Python `if report:` guard — fall through rather than lock in nothing.
|
|
1124
1405
|
if (report !== null && report.length > 0) {
|
|
1125
1406
|
for (const r of report)
|
|
1126
1407
|
resolved.set(r.unit, r);
|
|
1408
|
+
coverage.unitsMatched = report.length;
|
|
1127
1409
|
remaining = remaining.filter((u) => !resolved.has(u));
|
|
1128
1410
|
}
|
|
1129
1411
|
}
|
|
@@ -1140,6 +1422,6 @@ export function resolveCoverage(units, options = {}) {
|
|
|
1140
1422
|
resolved.set(r.unit, r);
|
|
1141
1423
|
}
|
|
1142
1424
|
}
|
|
1143
|
-
return units.map((unit) => resolved.get(unit));
|
|
1425
|
+
return { results: units.map((unit) => resolved.get(unit)), coverage };
|
|
1144
1426
|
}
|
|
1145
1427
|
//# sourceMappingURL=coverage.js.map
|