canary-test-cli 6.6.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/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/static-linter.js +44 -4
- 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/gate-result.js +27 -4
- package/dist/overlay-commands.js +31 -3
- package/package.json +2 -2
|
@@ -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
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub REST pagination for the guardian's read paths (#528).
|
|
3
|
+
*
|
|
4
|
+
* Both guardian clients used to call GitHub's list endpoints bare, which caps
|
|
5
|
+
* silently at the API default of 30 rows. That is the worst shape a denominator
|
|
6
|
+
* can take: a zero would look wrong, but "30 of 30" reads as a complete sample.
|
|
7
|
+
* A PR past 30 comments hid the sticky comment; a sticky past 30 reactions
|
|
8
|
+
* biased the precision tally toward whichever verdicts sorted first.
|
|
9
|
+
*
|
|
10
|
+
* The loop lives here, behind an injected {@link PageReader}, so it is unit
|
|
11
|
+
* tested while the network stays quarantined in the clients that construct the
|
|
12
|
+
* real reader.
|
|
13
|
+
*/
|
|
14
|
+
/** GitHub's maximum page size for list endpoints. */
|
|
15
|
+
export const DEFAULT_PER_PAGE = 100;
|
|
16
|
+
/**
|
|
17
|
+
* Page ceiling ({@link DEFAULT_PER_PAGE} * this = 2000 rows). Crossing it
|
|
18
|
+
* throws rather than returning what was read so far: a partial list that the
|
|
19
|
+
* caller cannot distinguish from a complete one is the defect this module
|
|
20
|
+
* exists to remove, and rebuilding it inside the fix would be worse than the
|
|
21
|
+
* original.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_PAGES = 20;
|
|
24
|
+
/**
|
|
25
|
+
* `<url>; rel="next"` entries in a `Link` header. The angle brackets are
|
|
26
|
+
* required — a header with a bare `rel="next"` and no URL yields no match, so
|
|
27
|
+
* a malformed header degrades to "no next page" instead of a guessed URL.
|
|
28
|
+
*/
|
|
29
|
+
const LINK_ENTRY = /<([^>]+)>\s*;\s*rel\s*=\s*"?([a-zA-Z]+)"?/g;
|
|
30
|
+
/** The `rel="next"` URL from a `Link` header, or null if there is no next. */
|
|
31
|
+
export function parseNextLink(header) {
|
|
32
|
+
if (header === null || header.trim() === '')
|
|
33
|
+
return null;
|
|
34
|
+
for (const match of header.matchAll(LINK_ENTRY)) {
|
|
35
|
+
if (match[2].toLowerCase() !== 'next')
|
|
36
|
+
continue;
|
|
37
|
+
const url = match[1].trim();
|
|
38
|
+
// Only absolute http(s) — never relay a relative or garbage target.
|
|
39
|
+
return /^https?:\/\//i.test(url) ? url : null;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Request the largest page GitHub will serve. An explicit `per_page` already on
|
|
45
|
+
* the URL wins, so a caller can still ask for a small page deliberately.
|
|
46
|
+
*/
|
|
47
|
+
export function withPerPage(url, perPage = DEFAULT_PER_PAGE) {
|
|
48
|
+
const parsed = new URL(url);
|
|
49
|
+
if (!parsed.searchParams.has('per_page')) {
|
|
50
|
+
parsed.searchParams.set('per_page', String(perPage));
|
|
51
|
+
}
|
|
52
|
+
return parsed.toString();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Read every page from `startUrl`, following `Link: rel="next"`.
|
|
56
|
+
*
|
|
57
|
+
* Throws on a `Link` cycle or past {@link MAX_PAGES}; a non-array body counts
|
|
58
|
+
* as zero rows (matching the clients' prior defensive shape for an error
|
|
59
|
+
* payload). Never returns a short list quietly.
|
|
60
|
+
*/
|
|
61
|
+
export async function readAllPages(startUrl, read) {
|
|
62
|
+
const rows = [];
|
|
63
|
+
const visited = new Set();
|
|
64
|
+
let url = withPerPage(startUrl);
|
|
65
|
+
let pages = 0;
|
|
66
|
+
while (url !== null) {
|
|
67
|
+
if (visited.has(url)) {
|
|
68
|
+
throw new Error(`GitHub paging cycle: ${url} was already read`);
|
|
69
|
+
}
|
|
70
|
+
visited.add(url);
|
|
71
|
+
pages += 1;
|
|
72
|
+
if (pages > MAX_PAGES) {
|
|
73
|
+
throw new Error(`GitHub paging exceeded ${MAX_PAGES} pages (${MAX_PAGES * DEFAULT_PER_PAGE}+ rows) ` +
|
|
74
|
+
`starting at ${startUrl} -- refusing to report a truncated read`);
|
|
75
|
+
}
|
|
76
|
+
const page = await read(url);
|
|
77
|
+
if (Array.isArray(page.body))
|
|
78
|
+
rows.push(...page.body);
|
|
79
|
+
url = parseNextLink(page.linkHeader);
|
|
80
|
+
}
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A {@link PageReader} over `fetch`. The ONLY place in this module that touches
|
|
85
|
+
* the network; `onError` lets each client keep its own status mapping (the
|
|
86
|
+
* comment client distinguishes 403 as a permission error, the reactions client
|
|
87
|
+
* does not).
|
|
88
|
+
*/
|
|
89
|
+
export function restPageReader(headers, onError) {
|
|
90
|
+
return async (url) => {
|
|
91
|
+
const resp = await fetch(url, { method: 'GET', headers });
|
|
92
|
+
if (!resp.ok)
|
|
93
|
+
throw onError(resp.status, url);
|
|
94
|
+
return { body: await resp.json(), linkHeader: resp.headers.get('link') };
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=github-paging.js.map
|