canary-test-cli 6.1.0 → 6.3.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/dist/engine/analysis/reachability.js +305 -0
- package/dist/engine/cli-commands.js +2 -0
- package/dist/engine/cli.core.js +1 -0
- package/dist/engine/core/company-knowledge.js +131 -40
- package/dist/engine/core/migrator.js +317 -20
- package/dist/engine/guardian/agent-tier.js +7 -2
- package/dist/engine/guardian/cli.js +232 -9
- package/dist/engine/guardian/coverage.js +90 -0
- package/dist/engine/guardian/pr-check.js +194 -20
- package/package.json +1 -1
|
@@ -55,7 +55,7 @@ import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
|
|
|
55
55
|
import { extractApiDiff } from './diff-extractor.js';
|
|
56
56
|
import { HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
|
|
57
57
|
import { mapImpact } from './impact-mapper.js';
|
|
58
|
-
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
|
|
58
|
+
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
|
|
59
59
|
import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
|
|
60
60
|
import { buildSummary } from './summary-emitter.js';
|
|
61
61
|
import { resolveTier } from './tier.js';
|
|
@@ -258,22 +258,206 @@ const AUTHORED_SENTINEL_NAME = 'canary-guardian-authored';
|
|
|
258
258
|
function authoredSentinelPath(deps, root) {
|
|
259
259
|
return join(gitDir(deps, root), AUTHORED_SENTINEL_NAME);
|
|
260
260
|
}
|
|
261
|
+
// The sentinel's FIRST line stamps the HEAD the guardian authored at:
|
|
262
|
+
// `HEAD <sha>`. Every line after it is one authored path. Anchored at the start
|
|
263
|
+
// of the body and hex-only, with a trailing `[ \t\r]*` so a CRLF-written file
|
|
264
|
+
// still parses -- anything else reads as malformed, which fails OPEN.
|
|
265
|
+
const SENTINEL_HEAD_RE = /^HEAD ([0-9a-fA-F]{7,64})[ \t\r]*(?:\n|$)/;
|
|
266
|
+
/**
|
|
267
|
+
* Parse the `HEAD <sha>` stamp off a sentinel body; `null` when malformed.
|
|
268
|
+
*
|
|
269
|
+
* Malformed covers empty, headerless (the pre-#456 paths-only format), and any
|
|
270
|
+
* unparseable first line. Callers MUST treat `null` as "cannot verify" and fail
|
|
271
|
+
* OPEN -- an unreadable sentinel must never wedge authoring off (#456).
|
|
272
|
+
*/
|
|
273
|
+
function sentinelHeadStamp(text) {
|
|
274
|
+
const match = SENTINEL_HEAD_RE.exec(text);
|
|
275
|
+
return match === null ? null : match[1].toLowerCase();
|
|
276
|
+
}
|
|
277
|
+
/** Current `HEAD` sha for `root`, or `null` when git/HEAD is unavailable. */
|
|
278
|
+
function headSha(deps, root) {
|
|
279
|
+
const res = deps.runGit(['rev-parse', 'HEAD'], root);
|
|
280
|
+
if (res === null || res.code !== 0)
|
|
281
|
+
return null; // no git / no commits
|
|
282
|
+
return res.stdout.trim().toLowerCase() || null;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Is the loop guard live -- i.e. does a sentinel stamped at the CURRENT `HEAD`
|
|
286
|
+
* exist?
|
|
287
|
+
*
|
|
288
|
+
* This is the surviving half of the stage-and-block-once contract (#456). The
|
|
289
|
+
* component that CLEARED the sentinel on the next commit
|
|
290
|
+
* (`hooks/guardian_precommit.py`) was deleted as dead code in #449, which left
|
|
291
|
+
* `author-plan` fail-closed forever: author once in a clone and Tier-2 authoring
|
|
292
|
+
* never ran again. Stamping HEAD makes the guard self-expiring -- once the human
|
|
293
|
+
* reviews and commits the staged tests, `HEAD` moves, the stamp stops matching,
|
|
294
|
+
* and authoring re-enables itself with no manual step and no hook.
|
|
295
|
+
*
|
|
296
|
+
* Every unverifiable state FAILS OPEN (returns `false`, authoring allowed):
|
|
297
|
+
* missing or unreadable sentinel, a malformed/absent `HEAD` header, or a `HEAD`
|
|
298
|
+
* we cannot resolve. Fail-closed here is exactly the bug being fixed.
|
|
299
|
+
*/
|
|
300
|
+
function authoredSentinelActive(deps, root) {
|
|
301
|
+
let body;
|
|
302
|
+
try {
|
|
303
|
+
body = readFileSync(authoredSentinelPath(deps, root), 'utf-8');
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return false; // absent or unreadable -> fail open
|
|
307
|
+
}
|
|
308
|
+
const stamp = sentinelHeadStamp(body);
|
|
309
|
+
if (stamp === null)
|
|
310
|
+
return false; // malformed -> fail open
|
|
311
|
+
const head = headSha(deps, root);
|
|
312
|
+
if (head === null)
|
|
313
|
+
return false; // unverifiable -> fail open
|
|
314
|
+
return head === stamp;
|
|
315
|
+
}
|
|
261
316
|
/**
|
|
262
317
|
* Return raw unified-diff text from a source.
|
|
263
318
|
*
|
|
264
319
|
* `source === '-'` reads stdin; a path reads that file; `null` runs `git diff`
|
|
265
320
|
* and falls back to `git diff --staged` when the worktree is clean.
|
|
321
|
+
*
|
|
322
|
+
* This is the AT-DESK resolution: the working tree is the subject. `pr-check`
|
|
323
|
+
* uses {@link readPrDiff} instead, which prefers the PR diff in CI (#369).
|
|
266
324
|
*/
|
|
267
325
|
function readDiff(source, deps) {
|
|
268
326
|
if (source === '-')
|
|
269
327
|
return deps.readStdin();
|
|
270
328
|
if (source !== null)
|
|
271
329
|
return readFileSync(source, 'utf-8');
|
|
330
|
+
return readWorktreeDiff(deps);
|
|
331
|
+
}
|
|
332
|
+
/** `git diff`, falling back to `git diff --staged` on a clean worktree. */
|
|
333
|
+
function readWorktreeDiff(deps) {
|
|
272
334
|
const unstaged = deps.runGit(['diff'])?.stdout ?? '';
|
|
273
335
|
if (unstaged.trim())
|
|
274
336
|
return unstaged;
|
|
275
337
|
return deps.runGit(['diff', '--staged'])?.stdout ?? '';
|
|
276
338
|
}
|
|
339
|
+
/** True when the process looks like a CI runner rather than a dev worktree. */
|
|
340
|
+
function isCiContext(env) {
|
|
341
|
+
return Boolean(env['GITHUB_ACTIONS'] || env['CI']);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Read `pull_request.base.sha` from the Actions event payload, if present.
|
|
345
|
+
*
|
|
346
|
+
* Optional chaining over a narrow interface (rather than an `unknown` +
|
|
347
|
+
* `typeof` ladder) keeps this at one branch per real failure mode: unreadable
|
|
348
|
+
* file, unparseable JSON, or a payload without a usable sha.
|
|
349
|
+
*/
|
|
350
|
+
function eventBaseSha(env) {
|
|
351
|
+
const eventPath = env['GITHUB_EVENT_PATH'];
|
|
352
|
+
if (!eventPath)
|
|
353
|
+
return null;
|
|
354
|
+
let sha;
|
|
355
|
+
try {
|
|
356
|
+
const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
|
|
357
|
+
sha = event?.pull_request?.base?.sha;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return typeof sha === 'string' && sha.trim() ? sha.trim() : null;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Base-rev candidates for the PR diff, most-preferred first.
|
|
366
|
+
*
|
|
367
|
+
* `origin/<ref>` leads because `actions/checkout` fetches the base branch under
|
|
368
|
+
* the remote namespace and usually does NOT create a local branch for it; the
|
|
369
|
+
* bare `<ref>` covers checkouts that do. The event payload's `base.sha` is the
|
|
370
|
+
* last resort — exact, but only present on `pull_request` events.
|
|
371
|
+
*/
|
|
372
|
+
function baseRefCandidates(env) {
|
|
373
|
+
const candidates = [];
|
|
374
|
+
const baseRef = env['GITHUB_BASE_REF']?.trim();
|
|
375
|
+
if (baseRef)
|
|
376
|
+
candidates.push(`origin/${baseRef}`, baseRef);
|
|
377
|
+
const sha = eventBaseSha(env);
|
|
378
|
+
if (sha)
|
|
379
|
+
candidates.push(sha);
|
|
380
|
+
return candidates;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Return the first base candidate that actually resolves to a commit locally.
|
|
384
|
+
*
|
|
385
|
+
* A shallow clone (`fetch-depth: 1`, the `actions/checkout` default) will NOT
|
|
386
|
+
* have the base commit, so every candidate fails `rev-parse` and we return
|
|
387
|
+
* `null` — the caller then falls back to the worktree diff and warns.
|
|
388
|
+
*/
|
|
389
|
+
function resolveBaseRev(deps) {
|
|
390
|
+
for (const candidate of baseRefCandidates(deps.env)) {
|
|
391
|
+
const res = deps.runGit([
|
|
392
|
+
'rev-parse',
|
|
393
|
+
'--verify',
|
|
394
|
+
'--quiet',
|
|
395
|
+
`${candidate}^{commit}`,
|
|
396
|
+
]);
|
|
397
|
+
if (res !== null && res.code === 0 && res.stdout.trim())
|
|
398
|
+
return candidate;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Resolve the diff `pr-check` should scope, preferring the PR diff in CI (#369).
|
|
404
|
+
*
|
|
405
|
+
* An explicit `--diff` (stdin or file) always wins and never shells out. With
|
|
406
|
+
* `--diff` omitted:
|
|
407
|
+
*
|
|
408
|
+
* - **In CI** with a resolvable base rev → `git diff <base>...HEAD`. The
|
|
409
|
+
* TRIPLE-dot form diffs against the merge base, so commits that land on the
|
|
410
|
+
* base branch mid-PR never appear as part of this PR's changed surface.
|
|
411
|
+
* - **Otherwise** → the at-desk working-tree diff ({@link readWorktreeDiff}).
|
|
412
|
+
*
|
|
413
|
+
* The legacy behavior was the working-tree diff unconditionally, which is empty
|
|
414
|
+
* on a clean CI checkout — the gate then scoped zero paths and exited 0, so an
|
|
415
|
+
* adopting repo could not tell a working gate from a broken one.
|
|
416
|
+
*/
|
|
417
|
+
export function readPrDiff(source, deps) {
|
|
418
|
+
if (source === '-') {
|
|
419
|
+
return { text: deps.readStdin(), origin: 'stdin', base: null };
|
|
420
|
+
}
|
|
421
|
+
if (source !== null) {
|
|
422
|
+
return { text: readFileSync(source, 'utf-8'), origin: 'file', base: null };
|
|
423
|
+
}
|
|
424
|
+
if (isCiContext(deps.env)) {
|
|
425
|
+
const base = resolveBaseRev(deps);
|
|
426
|
+
if (base !== null) {
|
|
427
|
+
const res = deps.runGit(['diff', `${base}...HEAD`]);
|
|
428
|
+
if (res !== null && res.code === 0) {
|
|
429
|
+
return { text: res.stdout, origin: 'ci-base', base };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { text: readWorktreeDiff(deps), origin: 'worktree', base: null };
|
|
434
|
+
}
|
|
435
|
+
const EMPTY_CI_DIFF_NOTICE = 'guardian: 0 changed paths — fell back to a working-tree `git diff`, which ' +
|
|
436
|
+
'is empty on a clean CI checkout, so NOTHING was verified. Pass ' +
|
|
437
|
+
'`--diff <base>...<head>`, or checkout with `fetch-depth: 0` so the PR base ' +
|
|
438
|
+
'ref resolves automatically.';
|
|
439
|
+
/**
|
|
440
|
+
* Warn LOUDLY when a CI run scoped zero paths off the worktree fallback (#369).
|
|
441
|
+
*
|
|
442
|
+
* Fires only for the exact broken shape — `--diff` omitted, CI detected, base
|
|
443
|
+
* rev unresolvable, and zero changed paths. A diff that DID carry paths which
|
|
444
|
+
* were then all skipped is a legitimate no-op and stays quiet.
|
|
445
|
+
*
|
|
446
|
+
* Deliberately non-blocking: it annotates (`::warning::` + step summary +
|
|
447
|
+
* stderr) rather than exiting non-zero, so adopting an engine upgrade never
|
|
448
|
+
* flips a green build red — but a silent green no-op becomes impossible.
|
|
449
|
+
*/
|
|
450
|
+
function warnIfEmptyCiDiff(resolved, unitCount, deps) {
|
|
451
|
+
if (resolved.origin !== 'worktree')
|
|
452
|
+
return;
|
|
453
|
+
if (unitCount > 0)
|
|
454
|
+
return;
|
|
455
|
+
if (!isCiContext(deps.env))
|
|
456
|
+
return;
|
|
457
|
+
deps.out(degradationAnnotation(EMPTY_CI_DIFF_NOTICE));
|
|
458
|
+
appendStepSummary(deps.env, EMPTY_CI_DIFF_NOTICE);
|
|
459
|
+
deps.err(EMPTY_CI_DIFF_NOTICE);
|
|
460
|
+
}
|
|
277
461
|
// --- analyze ------------------------------------------------------------------
|
|
278
462
|
function loadSpec(path, deps) {
|
|
279
463
|
if (!existsSync(path)) {
|
|
@@ -494,6 +678,10 @@ async function postStickyComment(findings, resolution, deps) {
|
|
|
494
678
|
appendStepSummary(deps.env, res.notice);
|
|
495
679
|
}
|
|
496
680
|
}
|
|
681
|
+
/** The gate's no-op line, shared by the pre- and post-filter exits. */
|
|
682
|
+
function nothingToVerify(skippedCount) {
|
|
683
|
+
return `guardian: nothing to verify (${skippedCount} path(s) skipped).`;
|
|
684
|
+
}
|
|
497
685
|
async function prCheckCmd(opts, deps) {
|
|
498
686
|
const [config, warning] = loadGuardianConfig(opts.config);
|
|
499
687
|
if (warning !== null) {
|
|
@@ -507,8 +695,12 @@ async function prCheckCmd(opts, deps) {
|
|
|
507
695
|
throw new CliExit(0);
|
|
508
696
|
}
|
|
509
697
|
const effectiveGate = opts.gate ?? config.pr_gate;
|
|
510
|
-
|
|
698
|
+
// #369: in CI an omitted `--diff` resolves the PR diff from the base ref;
|
|
699
|
+
// the working-tree fallback is empty on a clean checkout.
|
|
700
|
+
const resolvedDiff = readPrDiff(opts.diff ?? null, deps);
|
|
701
|
+
const diffText = resolvedDiff.text;
|
|
511
702
|
const units = scopeDiff(diffText);
|
|
703
|
+
warnIfEmptyCiDiff(resolvedDiff, units.length, deps);
|
|
512
704
|
// SC-2: drop docs/config-only units matching skipGlobs.
|
|
513
705
|
const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
|
|
514
706
|
// FIX A: drop test-path units -- a test does not itself need a test.
|
|
@@ -521,9 +713,9 @@ async function prCheckCmd(opts, deps) {
|
|
|
521
713
|
const weakFindings = config.weak_tests
|
|
522
714
|
? buildWeakTestFindings(testUnits, diffText)
|
|
523
715
|
: [];
|
|
716
|
+
const preFilterSkipped = skipped.length + testUnits.length + barrelUnits.length;
|
|
524
717
|
if (kept.length === 0 && weakFindings.length === 0) {
|
|
525
|
-
deps.out(
|
|
526
|
-
`(${skipped.length + testUnits.length + barrelUnits.length} path(s) skipped).`);
|
|
718
|
+
deps.out(nothingToVerify(preFilterSkipped));
|
|
527
719
|
throw new CliExit(0);
|
|
528
720
|
}
|
|
529
721
|
const results = resolveCoverage(kept, {
|
|
@@ -532,10 +724,21 @@ async function prCheckCmd(opts, deps) {
|
|
|
532
724
|
// (depth 1); soft stays unbounded. An explicit config value wins.
|
|
533
725
|
graphMaxDepth: effectiveGraphDepth(config, effectiveGate),
|
|
534
726
|
});
|
|
727
|
+
// #413: drop uncovered HEURISTIC verdicts on paths a naming heuristic can
|
|
728
|
+
// never judge (non-source, or an excluded glob). Coverage/graph-verified
|
|
729
|
+
// verdicts on the same paths are real evidence and survive.
|
|
730
|
+
const [scoredResults, noiseResults] = filterHeuristicNoise(results, opts.heuristicExclude ?? config.heuristic_exclude);
|
|
535
731
|
const findings = [
|
|
536
|
-
...applySuppressions(buildFindings(
|
|
732
|
+
...applySuppressions(buildFindings(scoredResults)),
|
|
537
733
|
...weakFindings,
|
|
538
734
|
];
|
|
735
|
+
// #413: if the heuristic filter consumed every scorable unit, report it as a
|
|
736
|
+
// SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
|
|
737
|
+
// must be able to tell "nothing was judgeable" from "everything passed".
|
|
738
|
+
if (scoredResults.length === 0 && findings.length === 0) {
|
|
739
|
+
deps.out(nothingToVerify(preFilterSkipped + noiseResults.length));
|
|
740
|
+
throw new CliExit(0);
|
|
741
|
+
}
|
|
539
742
|
// SC-5 (PR half): resolve the requested tier against actual capability. No
|
|
540
743
|
// agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
|
|
541
744
|
// tier 0 with a LOUD degradation notice.
|
|
@@ -602,7 +805,10 @@ function buildGaps(diffText, config, coveragePath, graphMaxDepth) {
|
|
|
602
805
|
if (kept.length === 0)
|
|
603
806
|
return [];
|
|
604
807
|
const results = resolveCoverage(kept, { coveragePath, graphMaxDepth });
|
|
605
|
-
|
|
808
|
+
// #413: never hand the authoring tier a heuristic FP -- a generated "test" for
|
|
809
|
+
// a config dotfile is worse noise than the finding was.
|
|
810
|
+
const [scored] = filterHeuristicNoise(results, config.heuristic_exclude);
|
|
811
|
+
return applySuppressions(buildFindings(scored));
|
|
606
812
|
}
|
|
607
813
|
/** Serialize a {@link GeneratedTest} intent for the SKILL (JSON-safe). */
|
|
608
814
|
function intentDict(intent) {
|
|
@@ -634,7 +840,8 @@ function authorPlanCmd(opts, deps) {
|
|
|
634
840
|
const ctx = new AuthoringContext(config.precommit_author_tests, effective, {
|
|
635
841
|
is_fork: isForkContext(deps.env),
|
|
636
842
|
repo_root: repoRoot,
|
|
637
|
-
|
|
843
|
+
// #456: HEAD-stamped, so the guard expires on the next commit by itself.
|
|
844
|
+
authored_sentinel_present: authoredSentinelActive(deps, repoRoot),
|
|
638
845
|
});
|
|
639
846
|
const results = deps.makeAgentTier().author_tests(gaps, ctx);
|
|
640
847
|
const decision = decideBlock(results);
|
|
@@ -648,11 +855,23 @@ function authorPlanCmd(opts, deps) {
|
|
|
648
855
|
};
|
|
649
856
|
deps.out(ensureAscii(JSON.stringify(payload, null, 2)));
|
|
650
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* Record the authored paths in the loop-guard sentinel, stamped with the HEAD
|
|
860
|
+
* they were authored at (#456).
|
|
861
|
+
*
|
|
862
|
+
* The `HEAD <sha>` header is what makes the guard self-expiring: `author-plan`
|
|
863
|
+
* honors it only while `HEAD` still matches, so the human's review commit clears
|
|
864
|
+
* it implicitly. When `HEAD` cannot be resolved (a repo with no commits, or no
|
|
865
|
+
* git at all) the header is omitted -- an unstamped sentinel reads as malformed
|
|
866
|
+
* and FAILS OPEN, which is the safe direction.
|
|
867
|
+
*/
|
|
651
868
|
function markAuthoredCmd(opts, deps) {
|
|
652
869
|
const root = gitToplevel(deps);
|
|
653
870
|
const sentinel = authoredSentinelPath(deps, root);
|
|
654
871
|
mkdirSync(dirname(sentinel), { recursive: true });
|
|
655
|
-
const
|
|
872
|
+
const head = headSha(deps, root);
|
|
873
|
+
const header = head === null ? '' : `HEAD ${head}\n`;
|
|
874
|
+
const body = header + opts.path.map((p) => `${p}\n`).join('');
|
|
656
875
|
writeFileSync(sentinel, body, 'utf-8');
|
|
657
876
|
deps.out(`guardian: recorded ${opts.path.length} authored path(s) ${RIGHT_ARROW} ${sentinel}`);
|
|
658
877
|
}
|
|
@@ -729,11 +948,15 @@ export function createGuardianCommand(depsInit = {}) {
|
|
|
729
948
|
program
|
|
730
949
|
.command('pr-check')
|
|
731
950
|
.description('Tier 0 deterministic PR guardian: scope, resolve, gate.')
|
|
732
|
-
.option('--diff <diff>', "Diff file, '-' for stdin, or omit to
|
|
951
|
+
.option('--diff <diff>', "Diff file, '-' for stdin, or omit to auto-resolve: the PR diff " +
|
|
952
|
+
'(`<base>...HEAD`) in CI, else the local working-tree `git diff`.')
|
|
733
953
|
.option('--coverage <path>', 'Coverage report path (lcov/json).')
|
|
734
954
|
.addOption(new Option('--format <fmt>', 'comment|json|text').default('comment'))
|
|
735
955
|
.addOption(new Option('--config <path>').default('harness.config.json'))
|
|
736
956
|
.option('--gate <gate>', 'Override config gate: soft|hard')
|
|
957
|
+
.option('--heuristic-exclude <glob>', 'Glob whose paths never produce a heuristic-tier finding (repeatable). ' +
|
|
958
|
+
'Replaces canary.guardian.pr.heuristicExclude for this run. ' +
|
|
959
|
+
'Coverage/graph-verified findings are unaffected.', (value, previous) => [...(previous ?? []), value])
|
|
737
960
|
.option('--post-comment', 'Post/update the sticky PR comment via the GitHub API (CI).')
|
|
738
961
|
.option('--emit-analysis', 'Write the finding record to the .harness/analyses/ channel ' +
|
|
739
962
|
'(harness handoff, #899); falls back LOUDLY to the sticky comment ' +
|
|
@@ -682,6 +682,96 @@ const TEST_PATH_RE = /(^|\/)tests?\/|(^|\/)test_[^/]*\.py$|\.test\.[^/]+$|\.spec
|
|
|
682
682
|
export function isTestPath(path) {
|
|
683
683
|
return TEST_PATH_RE.test(path);
|
|
684
684
|
}
|
|
685
|
+
/**
|
|
686
|
+
* Extensions that denote hand-authored, executable program source (#413).
|
|
687
|
+
*
|
|
688
|
+
* The membership rule is deliberately simple and defensible: **a programming
|
|
689
|
+
* language belongs; data, config, markup, and style do not.** `.sh` is in (it is
|
|
690
|
+
* executable logic — bats/shunit2 exist); `.json`, `.yaml`, `.sql`, `.css`, and
|
|
691
|
+
* `.html` are out (nothing a naming heuristic could meaningfully judge).
|
|
692
|
+
*
|
|
693
|
+
* A repo that disagrees at the margins tunes the glob layer
|
|
694
|
+
* (`canary.guardian.pr.heuristicExclude`) rather than this list.
|
|
695
|
+
*/
|
|
696
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
697
|
+
// TS/JS + component dialects.
|
|
698
|
+
'.ts',
|
|
699
|
+
'.tsx',
|
|
700
|
+
'.mts',
|
|
701
|
+
'.cts',
|
|
702
|
+
'.js',
|
|
703
|
+
'.jsx',
|
|
704
|
+
'.mjs',
|
|
705
|
+
'.cjs',
|
|
706
|
+
'.vue',
|
|
707
|
+
'.svelte',
|
|
708
|
+
'.astro',
|
|
709
|
+
// Python / Ruby / PHP / Perl / Lua.
|
|
710
|
+
'.py',
|
|
711
|
+
'.pyi',
|
|
712
|
+
'.rb',
|
|
713
|
+
'.php',
|
|
714
|
+
'.pl',
|
|
715
|
+
'.pm',
|
|
716
|
+
'.lua',
|
|
717
|
+
// JVM + .NET.
|
|
718
|
+
'.java',
|
|
719
|
+
'.kt',
|
|
720
|
+
'.kts',
|
|
721
|
+
'.scala',
|
|
722
|
+
'.groovy',
|
|
723
|
+
'.clj',
|
|
724
|
+
'.cljs',
|
|
725
|
+
'.cs',
|
|
726
|
+
'.fs',
|
|
727
|
+
'.vb',
|
|
728
|
+
// Systems.
|
|
729
|
+
'.go',
|
|
730
|
+
'.rs',
|
|
731
|
+
'.c',
|
|
732
|
+
'.h',
|
|
733
|
+
'.cc',
|
|
734
|
+
'.cpp',
|
|
735
|
+
'.cxx',
|
|
736
|
+
'.hpp',
|
|
737
|
+
'.hh',
|
|
738
|
+
'.m',
|
|
739
|
+
'.mm',
|
|
740
|
+
'.swift',
|
|
741
|
+
// Functional / scientific / other.
|
|
742
|
+
'.ex',
|
|
743
|
+
'.exs',
|
|
744
|
+
'.erl',
|
|
745
|
+
'.dart',
|
|
746
|
+
'.r',
|
|
747
|
+
'.jl',
|
|
748
|
+
// Shell.
|
|
749
|
+
'.sh',
|
|
750
|
+
'.bash',
|
|
751
|
+
'.zsh',
|
|
752
|
+
'.ps1',
|
|
753
|
+
'.psm1',
|
|
754
|
+
]);
|
|
755
|
+
/**
|
|
756
|
+
* True if `path` looks like hand-authored program source (#413).
|
|
757
|
+
*
|
|
758
|
+
* Used to gate the Tier-3 naming heuristic. That heuristic asks "does any test
|
|
759
|
+
* file reference this file's stem or a top-level symbol?" — for a config
|
|
760
|
+
* dotfile, a lockfile, or a data blob there are no symbols and no test will
|
|
761
|
+
* ever name it, so the verdict is structurally always "uncovered": a guaranteed
|
|
762
|
+
* false positive rather than a signal. An extension-less file (`Makefile`,
|
|
763
|
+
* `Dockerfile`) and a bare dotfile (`.eslintrc`) are both non-source.
|
|
764
|
+
*/
|
|
765
|
+
export function isSourcePath(path) {
|
|
766
|
+
const base = basename(path);
|
|
767
|
+
// `.eslintrc` — `extname` calls this '' already, but a dotfile WITH a real
|
|
768
|
+
// extension (`.eslintrc.json`) must be judged on that extension, which the
|
|
769
|
+
// normal path handles.
|
|
770
|
+
const ext = extname(base).toLowerCase();
|
|
771
|
+
if (!ext)
|
|
772
|
+
return false;
|
|
773
|
+
return SOURCE_EXTENSIONS.has(ext);
|
|
774
|
+
}
|
|
685
775
|
/**
|
|
686
776
|
* Tier 2: derive coverage from the harness knowledge graph (`GRAPH_VERIFIED`).
|
|
687
777
|
*
|