@polymorphism-tech/morph-spec 10.7.1 → 10.8.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/README.md +2 -2
- package/bin/morph-spec.js +15 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +4 -2
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +32 -17
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +12 -4
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +43 -4
- package/framework/hooks/claude-code/core/conventional-commit.js +1 -1
- package/framework/hooks/claude-code/core/protect-spec-files.js +8 -1
- package/framework/hooks/claude-code/core/worktree-context.js +5 -1
- package/framework/hooks/shared/command-tokens.js +1 -1
- package/framework/hooks/shared/e2e-check.js +16 -0
- package/framework/hooks/shared/heavy-command.js +3 -2
- package/framework/hooks/shared/state-reader.js +3 -0
- package/framework/rules/implement-report.md +1 -1
- package/framework/rules/plan-grammar.md +3 -2
- package/framework/schemas/config.schema.json +6 -1
- package/framework/schemas/feature.schema.json +4 -2
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -7
- package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
- package/framework/skills/core/morph-apply/references/review.md +9 -3
- package/framework/skills/core/morph-plan/SKILL.md +3 -3
- package/framework/skills/core/morph-plan/references/critique.md +26 -18
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/templates/context/ARCHITECTURE.md +0 -3
- package/package.json +1 -1
- package/src/commands/graph/diff.js +9 -2
- package/src/commands/project/worktree.js +86 -7
- package/src/commands/state/approve.js +4 -7
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/finish.js +75 -4
- package/src/commands/state/gate-check.js +7 -2
- package/src/commands/state/report-check.js +33 -6
- package/src/commands/validation/plan-check.js +11 -2
- package/src/commands/validation/prove-red.js +95 -0
- package/src/commands/validation/verify.js +56 -40
- package/src/lib/ci/ci-verify.js +1 -106
- package/src/lib/ci/github-ci.js +5 -117
- package/src/lib/generators/architecture-scanner.js +1 -14
- package/src/lib/git/default-branch.js +28 -1
- package/src/lib/plan/artifact-vocabulary.js +7 -3
- package/src/lib/plan/critique.js +17 -0
- package/src/lib/review-confirmation.js +10 -11
- package/src/lib/review-reports.js +12 -3
- package/src/lib/risk/diff-risk.js +116 -0
- package/src/lib/risk-detector.js +1 -1
- package/src/lib/validators/code-budget/code-budget-validator.js +16 -4
- package/src/lib/validators/verify-runner.js +24 -2
- package/src/lib/verify/affected.js +3 -1
- package/src/utils/refusal-line.js +37 -0
- package/src/lib/ci/workflow-scaffold.js +0 -126
package/src/lib/ci/github-ci.js
CHANGED
|
@@ -1,45 +1,8 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from 'child_process';
|
|
2
|
-
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs';
|
|
3
|
-
import { tmpdir } from 'os';
|
|
4
|
-
import { join } from 'path';
|
|
5
2
|
import { resolveGh } from '../git/gh.js';
|
|
6
3
|
|
|
7
|
-
export const CI_WORKFLOW_DIR = '.github/workflows';
|
|
8
|
-
export const CI_WORKFLOW_FILE = 'morph-verify.yml';
|
|
9
|
-
export const CI_ARTIFACT = 'morph-verify';
|
|
10
|
-
export const CI_RESULT_FILE = 'morph-verify.json';
|
|
11
|
-
|
|
12
|
-
// A run that never judged the code (superseded, filtered out, quota) is no proof either way.
|
|
13
|
-
const NO_VERDICT = new Set(['cancelled', 'skipped', 'startup_failure', 'stale', 'neutral']);
|
|
14
|
-
|
|
15
|
-
const VERIFY_CALL = /morph-spec\S*\s+verify\b/;
|
|
16
|
-
|
|
17
4
|
const git = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
|
|
18
5
|
|
|
19
|
-
function gh(cwd, args, timeout = 15000) {
|
|
20
|
-
const { bin, prefix } = resolveGh();
|
|
21
|
-
return execFileSync(bin, [...prefix, ...args], { cwd, stdio: 'pipe', timeout }).toString();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Must call `morph-spec verify` AND publish its JSON: only that artifact becomes a stamp, and a green
|
|
25
|
-
// lint or deploy run proves nothing about the suite.
|
|
26
|
-
export function findCiWorkflow(root) {
|
|
27
|
-
const dir = join(root, CI_WORKFLOW_DIR);
|
|
28
|
-
if (!existsSync(dir)) return null;
|
|
29
|
-
const files = readdirSync(dir).filter((f) => /\.ya?ml$/i.test(f)).sort((a, b) =>
|
|
30
|
-
(a === CI_WORKFLOW_FILE ? -1 : b === CI_WORKFLOW_FILE ? 1 : a.localeCompare(b)));
|
|
31
|
-
for (const file of files) {
|
|
32
|
-
let text;
|
|
33
|
-
try {
|
|
34
|
-
text = readFileSync(join(dir, file), 'utf8');
|
|
35
|
-
} catch {
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
if (VERIFY_CALL.test(text) && text.includes(CI_RESULT_FILE)) return file;
|
|
39
|
-
}
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
6
|
export function currentBranch(root) {
|
|
44
7
|
try {
|
|
45
8
|
return git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
@@ -56,14 +19,13 @@ function originUrl(root) {
|
|
|
56
19
|
}
|
|
57
20
|
}
|
|
58
21
|
|
|
59
|
-
function
|
|
22
|
+
function ghAuthOk(root) {
|
|
60
23
|
const { bin, prefix } = resolveGh();
|
|
61
24
|
const res = spawnSync(bin, [...prefix, 'auth', 'status'], { cwd: root, stdio: 'pipe', timeout: 10000 });
|
|
62
|
-
|
|
63
|
-
return `${res.stdout ?? ''}${res.stderr ?? ''}`;
|
|
25
|
+
return !res.error && res.status === 0;
|
|
64
26
|
}
|
|
65
27
|
|
|
66
|
-
/** Never throws: every "no" is a reason the caller prints before
|
|
28
|
+
/** Never throws: every "no" is a reason the caller prints before running everything locally. */
|
|
67
29
|
export function ciAvailability(root, featureName, config) {
|
|
68
30
|
if (config?.project?.ci === false) return { ok: false, reason: 'project.ci = false no config.json' };
|
|
69
31
|
const url = originUrl(root);
|
|
@@ -73,17 +35,8 @@ export function ciAvailability(root, featureName, config) {
|
|
|
73
35
|
if (branch !== `morph/${featureName}`) {
|
|
74
36
|
return { ok: false, reason: `branch atual "${branch}" não é morph/${featureName}` };
|
|
75
37
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const scopes = auth.match(/Token scopes:\s*(.*)/i)?.[1] ?? null;
|
|
79
|
-
return { ok: true, branch, https: /^https?:/i.test(url), scopes };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Creating a workflow file over HTTPS needs the token's `workflow` scope, or GitHub rejects the whole push.
|
|
83
|
-
// Unknown scopes (fine-grained token, SSH remote) are not a refusal.
|
|
84
|
-
export function workflowPushProblem({ https, scopes }) {
|
|
85
|
-
if (!https || scopes === null) return null;
|
|
86
|
-
return /\bworkflow\b/.test(scopes) ? null : 'o token do gh não tem o escopo "workflow" (gh auth refresh -s workflow)';
|
|
38
|
+
if (!ghAuthOk(root)) return { ok: false, reason: 'gh ausente ou não autenticado' };
|
|
39
|
+
return { ok: true, branch };
|
|
87
40
|
}
|
|
88
41
|
|
|
89
42
|
/** Uncommitted or untracked files outside `.morph/`, plus the config; null when git did not answer. */
|
|
@@ -97,68 +50,3 @@ export function uncommittedCode(root) {
|
|
|
97
50
|
return out.split('\0').filter(Boolean).map((line) => line.slice(3))
|
|
98
51
|
.filter((path) => !path.startsWith('.morph/') || path === '.morph/config/config.json');
|
|
99
52
|
}
|
|
100
|
-
|
|
101
|
-
export function pushBranch(root, branch) {
|
|
102
|
-
try {
|
|
103
|
-
git(root, ['push', '-u', 'origin', branch]);
|
|
104
|
-
return null;
|
|
105
|
-
} catch (err) {
|
|
106
|
-
return (err.stderr?.toString() || err.message).trim().split('\n').slice(-3).join(' ');
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** @returns {Array<object>|null} newest first; null = gh did not answer */
|
|
111
|
-
export function listRuns(root, branch, workflow) {
|
|
112
|
-
try {
|
|
113
|
-
return JSON.parse(gh(root, ['run', 'list', '--branch', branch, '--workflow', workflow, '--limit', '20',
|
|
114
|
-
'--json', 'databaseId,status,conclusion,headSha,url,createdAt']));
|
|
115
|
-
} catch {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// `isCurrent` compares trees, not SHAs: the review agents and the archive commit only `.morph/`,
|
|
121
|
-
// and the workflow does not re-run for them.
|
|
122
|
-
export function pickRun(runs, isCurrent) {
|
|
123
|
-
return (runs || []).find((run) => !(run.status === 'completed' && NO_VERDICT.has(run.conclusion))
|
|
124
|
-
&& run.headSha && isCurrent(run.headSha)) ?? null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** @returns {object|null} the parsed `verify --json` the run uploaded; null when absent or unreadable */
|
|
128
|
-
export function downloadResult(root, runId) {
|
|
129
|
-
const dir = mkdtempSync(join(tmpdir(), 'morph-ci-'));
|
|
130
|
-
try {
|
|
131
|
-
gh(root, ['run', 'download', String(runId), '--name', CI_ARTIFACT, '--dir', dir], 120000);
|
|
132
|
-
return JSON.parse(readFileSync(join(dir, CI_RESULT_FILE), 'utf8'));
|
|
133
|
-
} catch {
|
|
134
|
-
return null;
|
|
135
|
-
} finally {
|
|
136
|
-
rmSync(dir, { recursive: true, force: true });
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Never decides: the caller turns `timeout`/`no-run`/`unknown` into "no CI proof", never into red. */
|
|
141
|
-
export async function waitForRun(root, { branch, workflow, isCurrent, timeoutMs, appearMs, pollMs = 15000,
|
|
142
|
-
sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onPending = () => {}, list = listRuns }) {
|
|
143
|
-
const start = now();
|
|
144
|
-
let seen = false;
|
|
145
|
-
let misses = 0;
|
|
146
|
-
for (;;) {
|
|
147
|
-
const runs = list(root, branch, workflow);
|
|
148
|
-
if (runs === null) {
|
|
149
|
-
if (++misses >= 3) return { state: 'unknown' };
|
|
150
|
-
} else {
|
|
151
|
-
misses = 0;
|
|
152
|
-
const run = pickRun(runs, isCurrent);
|
|
153
|
-
if (run?.status === 'completed') return { state: 'completed', run };
|
|
154
|
-
if (run) {
|
|
155
|
-
if (!seen) onPending(run);
|
|
156
|
-
seen = true;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
const elapsed = now() - start;
|
|
160
|
-
if (!seen && elapsed >= appearMs) return { state: 'no-run' };
|
|
161
|
-
if (elapsed >= timeoutMs) return { state: 'timeout' };
|
|
162
|
-
await sleep(pollMs);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
@@ -417,18 +417,8 @@ export async function renderArchitectureContext(projectPath, scanResult, options
|
|
|
417
417
|
}
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
-
let morphVersion = 'unknown';
|
|
421
|
-
try {
|
|
422
|
-
const pkgPath = path.join(cliRootDir(), 'package.json');
|
|
423
|
-
if (existsSync(pkgPath)) {
|
|
424
|
-
morphVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
425
|
-
}
|
|
426
|
-
} catch {}
|
|
427
|
-
|
|
428
420
|
const variables = {
|
|
429
421
|
projectName,
|
|
430
|
-
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0],
|
|
431
|
-
morphVersion,
|
|
432
422
|
hasDotnet: scanResult.entities.length > 0 || scanResult.endpoints.length > 0,
|
|
433
423
|
entities: scanResult.entities,
|
|
434
424
|
entityCount: scanResult.entities.length,
|
|
@@ -473,8 +463,7 @@ export async function renderArchitectureContext(projectPath, scanResult, options
|
|
|
473
463
|
|
|
474
464
|
function generateFallbackMarkdown(vars) {
|
|
475
465
|
const lines = [`# ${vars.projectName} — Architecture Context\n`];
|
|
476
|
-
lines.push(
|
|
477
|
-
lines.push(`> Last updated: ${vars.timestamp}\n`);
|
|
466
|
+
lines.push('> Auto-generated by morph-spec context refresh\n');
|
|
478
467
|
|
|
479
468
|
if (vars.hasDotnet) {
|
|
480
469
|
lines.push('## .NET Entities\n');
|
|
@@ -550,7 +539,5 @@ function generateFallbackMarkdown(vars) {
|
|
|
550
539
|
lines.push('');
|
|
551
540
|
}
|
|
552
541
|
}
|
|
553
|
-
|
|
554
|
-
lines.push(`---\n*Generated by morph-spec v${vars.morphVersion}*`);
|
|
555
542
|
return lines.join('\n');
|
|
556
543
|
}
|
|
@@ -28,6 +28,24 @@ export function detectDefaultBranch(cwd = process.cwd()) {
|
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Where a new feature branch starts: origin's default after a best-effort fetch, so a local default that
|
|
33
|
+
* was never pulled does not hand the feature an old base. Falls back to the local default.
|
|
34
|
+
*/
|
|
35
|
+
export function freshDefaultRef(cwd = process.cwd()) {
|
|
36
|
+
const branch = detectDefaultBranch(cwd);
|
|
37
|
+
if (!branch) return null;
|
|
38
|
+
try {
|
|
39
|
+
execFileSync('git', ['fetch', '-q', 'origin', branch], { cwd, stdio: 'pipe', timeout: 30_000 });
|
|
40
|
+
} catch { /* offline or no origin: the local default */ }
|
|
41
|
+
try {
|
|
42
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`], { cwd, stdio: 'pipe' });
|
|
43
|
+
return `origin/${branch}`;
|
|
44
|
+
} catch {
|
|
45
|
+
return branch;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
export function hasOriginRemote(cwd = process.cwd()) {
|
|
32
50
|
try {
|
|
33
51
|
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
@@ -107,8 +125,17 @@ export function changedPathsAgainst(baseRef, cwd = process.cwd()) {
|
|
|
107
125
|
|
|
108
126
|
let tracked;
|
|
109
127
|
try {
|
|
128
|
+
// From the merge-base: what the default gained after the branch point is not this feature's.
|
|
129
|
+
// origin's copy first: a local default that was never pulled holds the merge-base back.
|
|
130
|
+
let base = baseRef;
|
|
131
|
+
for (const ref of [`origin/${baseRef}`, baseRef]) {
|
|
132
|
+
try {
|
|
133
|
+
base = execFileSync('git', ['merge-base', 'HEAD', ref], { cwd, stdio: 'pipe' }).toString().trim() || baseRef;
|
|
134
|
+
break;
|
|
135
|
+
} catch { /* next ref */ }
|
|
136
|
+
}
|
|
110
137
|
tracked = splitLines(
|
|
111
|
-
execFileSync('git', ['diff', '--name-only',
|
|
138
|
+
execFileSync('git', ['diff', '--name-only', base], { cwd, stdio: 'pipe' }).toString()
|
|
112
139
|
);
|
|
113
140
|
} catch {
|
|
114
141
|
return [];
|
|
@@ -26,15 +26,19 @@ export const ARTIFACT_VOCABULARY = [
|
|
|
26
26
|
'u',
|
|
27
27
|
),
|
|
28
28
|
},
|
|
29
|
-
|
|
29
|
+
// A glob inside backticks is a value the change is about (a path a CI filters), not a claim about files.
|
|
30
|
+
{ term: 'glob **/', re: /\*\*\/|\/\*\*/, outsideCode: true },
|
|
30
31
|
];
|
|
31
32
|
|
|
33
|
+
const CODE_SPAN = /`[^`]*`/g;
|
|
34
|
+
|
|
32
35
|
/** Each term at most once, in vocabulary order. */
|
|
33
36
|
export function findArtifactTerms(text) {
|
|
34
37
|
const s = String(text ?? '');
|
|
38
|
+
const prose = s.replace(CODE_SPAN, '');
|
|
35
39
|
const found = [];
|
|
36
|
-
for (const { term, re } of ARTIFACT_VOCABULARY) {
|
|
37
|
-
if (re.test(s)) found.push(term);
|
|
40
|
+
for (const { term, re, outsideCode } of ARTIFACT_VOCABULARY) {
|
|
41
|
+
if (re.test(outsideCode ? prose : s)) found.push(term);
|
|
38
42
|
}
|
|
39
43
|
return found;
|
|
40
44
|
}
|
package/src/lib/plan/critique.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
3
3
|
import { featureGeneratesMedia } from './media.js';
|
|
4
|
+
import { SENSITIVE_LINE } from '../risk/diff-risk.js';
|
|
4
5
|
|
|
5
6
|
export const CRITIQUE_EXEMPT_WORK_TYPES = ['chore', 'hotfix'];
|
|
6
7
|
|
|
@@ -16,6 +17,22 @@ export function isSmallPlan(checks, planText = '') {
|
|
|
16
17
|
&& !featureGeneratesMedia(planText);
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Who writes `critica.md`. By default the session, with the grep checklist of morph-plan
|
|
22
|
+
* (references/critique.md): the premises it catches are what a grep confirms. A rung, generated
|
|
23
|
+
* media or a sensitive theme (PII, permission, sending to a third party, payment) asks for the
|
|
24
|
+
* fresh-context agent, whose blind spot is not the planner's.
|
|
25
|
+
*/
|
|
26
|
+
export function critiqueByAgent(checks, planText = '') {
|
|
27
|
+
const reasons = [];
|
|
28
|
+
const rungs = (checks?.checks ?? []).filter((c) => c.rung).map((c) => `${c.id} (${c.rung})`);
|
|
29
|
+
if (rungs.length) reasons.push(`degrau: ${rungs.join(', ')}`);
|
|
30
|
+
if (featureGeneratesMedia(planText)) reasons.push('mídia gerada');
|
|
31
|
+
const sensitive = String(planText).split(/\r?\n/).find((line) => SENSITIVE_LINE.test(line));
|
|
32
|
+
if (sensitive) reasons.push(`tema sensível: ${sensitive.trim().slice(0, 80)}`);
|
|
33
|
+
return { agent: reasons.length > 0, reasons };
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
const PLAN_HASH_RE = /^\*\*Plan-Hash:\*\*\s*`?([0-9a-f]{64})`?\s*$/m;
|
|
20
37
|
const APPLIED_RE = /^\*\*Aplicado-em:\*\*\s*`?([0-9a-f]{64})`?\s*$/gm;
|
|
21
38
|
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'child_process';
|
|
|
2
2
|
import { basename } from 'path';
|
|
3
3
|
import { treeMovedSince } from './git/evidence-staleness.js';
|
|
4
4
|
import { isTestOnly, securityReviewFor } from './risk-detector.js';
|
|
5
|
+
import { deltaRisk } from './risk/diff-risk.js';
|
|
5
6
|
|
|
6
7
|
function resolveCommit(cwd, ref) {
|
|
7
8
|
try {
|
|
@@ -38,30 +39,28 @@ function deltaIsNarrow(role, delta, cited) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* The one oracle of "this review is stale", read by `approve` and by `report-check` alike. A delta that
|
|
43
|
+
* touches only `.morph/`, docs, config, imports or comments (`deltaRisk` trivial) moves no role. Otherwise a
|
|
44
|
+
* role that shipped clean need not return when the delta stays inside what the blockers named or in
|
|
45
|
+
* tests (security: no sensitive path), and only once a role that opened blockers re-judged `head`.
|
|
45
46
|
*/
|
|
46
47
|
export function reviewConfirmation(cwd, reports, head) {
|
|
47
48
|
const cited = citedByBlockers(reports);
|
|
48
|
-
const
|
|
49
|
+
const behind = [];
|
|
49
50
|
let confirmed = false;
|
|
50
51
|
|
|
51
52
|
for (const [role, report] of Object.entries(reports)) {
|
|
52
53
|
if (report.status === 'missing' || !report.reviewedSha) continue;
|
|
53
54
|
const reviewed = resolveCommit(cwd, report.reviewedSha);
|
|
54
55
|
if (!reviewed) continue;
|
|
55
|
-
if (!treeMovedSince(cwd, reviewed, head)) {
|
|
56
|
+
if (!treeMovedSince(cwd, reviewed, head) || deltaRisk(cwd, reviewed, head)?.trivial) {
|
|
56
57
|
if (openedBlockers(report)) confirmed = true;
|
|
57
58
|
continue;
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
role,
|
|
61
|
-
reviewed,
|
|
62
|
-
skippable: isCleanShip(report) && deltaIsNarrow(role, changedBetween(cwd, reviewed, head), cited),
|
|
63
|
-
});
|
|
60
|
+
behind.push({ role, reviewed, narrow: isCleanShip(report) && deltaIsNarrow(role, changedBetween(cwd, reviewed, head), cited) });
|
|
64
61
|
}
|
|
65
62
|
|
|
63
|
+
const anyBlockers = Object.values(reports).some(openedBlockers);
|
|
64
|
+
const moved = behind.map(({ narrow, ...m }) => ({ ...m, skippable: narrow && (confirmed || !anyBlockers) }));
|
|
66
65
|
return { moved, confirmed };
|
|
67
66
|
}
|
|
@@ -30,11 +30,16 @@ const CHECKS_SECTION_RE = sectionRe('Checks');
|
|
|
30
30
|
const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
|
|
31
31
|
const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Fora dele manda o `risk` do diff
|
|
35
|
+
* (`src/lib/risk/diff-risk.js`): `low`/`none` = só o revisor, escrito pela sessão com o checklist.
|
|
36
|
+
* Sem risco medido, o tipo decide (chore leve).
|
|
37
|
+
*/
|
|
38
|
+
export function requiredReviewRoles({ workType, securityRequired = false, risk = null } = {}) {
|
|
35
39
|
// Sem `security` mesmo em caminho sensível: o humano assina todo hotfix.
|
|
36
40
|
if (workType === 'hotfix') return ['verifier'];
|
|
37
|
-
const
|
|
41
|
+
const light = risk ? ['low', 'none'].includes(risk) : workType === 'chore';
|
|
42
|
+
const base = light ? ['reviewer'] : ['verifier', 'reviewer'];
|
|
38
43
|
return securityRequired ? [...base, 'security'] : base;
|
|
39
44
|
}
|
|
40
45
|
|
|
@@ -78,12 +83,16 @@ function tableRows(body, keepHeader = () => true) {
|
|
|
78
83
|
return rows;
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
// A confirmation round that relists a blocker in the typed table to close it ("**Fechado.** …").
|
|
87
|
+
const CLOSED_ROW_RE = /^(fechado|resolvido|closed|resolved)\b/;
|
|
88
|
+
|
|
81
89
|
// `| id | tipo | onde | evidência |`; `untyped` = tipo fora de BLOCKER_TYPES.
|
|
82
90
|
function parseBlockers(body) {
|
|
83
91
|
const blockers = [];
|
|
84
92
|
const untyped = [];
|
|
85
93
|
for (const cells of tableRows(body, (header) => !isStatusHeader(header))) {
|
|
86
94
|
const row = { id: cleanId(cells[0]), type: normalize(cells[1]), where: cells[2] ?? '', evidence: cells[3] ?? '' };
|
|
95
|
+
if (CLOSED_ROW_RE.test(normalize(row.evidence.replace(/[*_`]/g, '')))) continue;
|
|
87
96
|
(BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
|
|
88
97
|
}
|
|
89
98
|
return { blockers, untyped };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { isTestPath } from '../graph/sanitize.js';
|
|
3
|
+
import { contractsTouched, migrationsTouched, securityReviewFor } from '../risk-detector.js';
|
|
4
|
+
|
|
5
|
+
const DOC_PATH = /(^|\/)docs\/|\.(md|mdx|txt|rst|adoc|png|jpe?g|gif|svg|webp|ico)$/i;
|
|
6
|
+
const CONFIG_PATH = /\.(json|jsonc|ya?ml|toml|ini|xml|editorconfig|env\.example)$|(^|\/)\.(gitignore|gitattributes|npmrc|nvmrc|prettierrc|eslintignore)$/i;
|
|
7
|
+
// Resolves or compiles everything: a change here reaches code the diff does not name.
|
|
8
|
+
const WIDE_PATH = /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|global\.json|Directory\.(Build|Packages)\.(props|targets)|tsconfig[^/]*\.json|[^/]+\.(csproj|sln|slnx|props|targets))$/i;
|
|
9
|
+
const TRIVIAL_LINE = /^\s*(|\/\/.*|#(?!include).*|\/\*.*|\*.*|\*\/|--.*|(import|using|export\s+\*\s+from|#include)\b.*|.*\brequire\(['"][^'"]+['"]\);?\s*)$/;
|
|
10
|
+
// PII, permission, sending to a third party, payment, contract: what turns a diff into a security review.
|
|
11
|
+
export const SENSITIVE_LINE = /\b(pii|lgpd|gdpr|cpf|cnpj|redact\w*|anonimi\w*|opt.?out|dnd|consent\w*|permiss\w*|authoriz\w*|autoriza\w*|requirerole|isadmin|password|senha|secret|apikey|api_key|whatsapp|sendmessage|sendtemplate|sms|webhook|smtp|sendemail|payment|pagamento|cobran\w*|invoice|fatura|checkout|refund|estorno|stripe|asaas|contrato)\b/i;
|
|
12
|
+
|
|
13
|
+
const camelSplit = (path) => path.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
|
|
14
|
+
|
|
15
|
+
function git(cwd, args) {
|
|
16
|
+
return execFileSync('git', args, { cwd, stdio: 'pipe', maxBuffer: 256 * 1024 * 1024 }).toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fileClass(path) {
|
|
20
|
+
if (path.startsWith('.morph/')) return 'state';
|
|
21
|
+
if (DOC_PATH.test(path)) return 'doc';
|
|
22
|
+
if (CONFIG_PATH.test(path) && !WIDE_PATH.test(path)) return 'config';
|
|
23
|
+
return isTestPath(path) || /(^|\/)e2e\//.test(path) ? 'test' : 'code';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Per file: `added`/`removed` lines and `deleted` (the whole file went away). */
|
|
27
|
+
export function diffLines(cwd, from, to = null) {
|
|
28
|
+
const range = to ? [from, to] : [from];
|
|
29
|
+
const text = git(cwd, ['-c', 'core.quotepath=off', 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range, '--', '.']);
|
|
30
|
+
const files = new Map();
|
|
31
|
+
let current = null;
|
|
32
|
+
for (const line of text.split('\n')) {
|
|
33
|
+
if (line.startsWith('diff --git ')) {
|
|
34
|
+
const path = line.split(' b/').pop();
|
|
35
|
+
current = { path, added: [], removed: [], deleted: false };
|
|
36
|
+
files.set(path, current);
|
|
37
|
+
} else if (!current) {
|
|
38
|
+
continue;
|
|
39
|
+
} else if (line.startsWith('deleted file mode')) {
|
|
40
|
+
current.deleted = true;
|
|
41
|
+
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
42
|
+
current.added.push(line.slice(1));
|
|
43
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
44
|
+
current.removed.push(line.slice(1));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!to) {
|
|
48
|
+
for (const path of git(cwd, ['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean)) {
|
|
49
|
+
files.set(path, { path, added: [''], removed: [], deleted: false, untracked: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...files.values()];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `trivial` = only imports, comments, blank lines; `removal` = logic only taken out; `logic` = logic added. */
|
|
56
|
+
export function changeKind(file) {
|
|
57
|
+
if (file.deleted) return 'removal';
|
|
58
|
+
if (file.untracked) return 'logic';
|
|
59
|
+
if (file.added.some((l) => !TRIVIAL_LINE.test(l))) return 'logic';
|
|
60
|
+
return file.removed.some((l) => !TRIVIAL_LINE.test(l)) ? 'removal' : 'trivial';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `low`: docs, `.morph/`, config, removals, imports. `normal`: logic in code or tests. `security` rides on either. */
|
|
64
|
+
export function classifyFiles(files) {
|
|
65
|
+
const reasons = [];
|
|
66
|
+
const relevant = files.filter((f) => fileClass(f.path) !== 'state');
|
|
67
|
+
const logic = relevant.filter((f) => ['code', 'test'].includes(fileClass(f.path)) && changeKind(f) === 'logic');
|
|
68
|
+
const wide = relevant.filter((f) => WIDE_PATH.test(f.path));
|
|
69
|
+
const paths = relevant.map((f) => f.path);
|
|
70
|
+
if (logic.length) reasons.push(`lógica em ${logic.slice(0, 3).map((f) => f.path).join(', ')}${logic.length > 3 ? ` (+${logic.length - 3})` : ''}`);
|
|
71
|
+
if (wide.length) reasons.push(`arquivo que alcança o projeto inteiro: ${wide[0].path}`);
|
|
72
|
+
const migration = migrationsTouched(paths);
|
|
73
|
+
const contract = contractsTouched(paths);
|
|
74
|
+
if (migration.length) reasons.push(`migration: ${migration[0]}`);
|
|
75
|
+
|
|
76
|
+
const securityReasons = [...securityReviewFor(paths.map(camelSplit)).reasons];
|
|
77
|
+
if (contract.length) securityReasons.push(`contrato: ${contract[0]}`);
|
|
78
|
+
const sensitive = relevant.find((f) => fileClass(f.path) === 'code' && f.added.some((l) => SENSITIVE_LINE.test(l)));
|
|
79
|
+
if (sensitive) {
|
|
80
|
+
const line = sensitive.added.find((l) => SENSITIVE_LINE.test(l)).trim();
|
|
81
|
+
securityReasons.push(`linha sensível em ${sensitive.path}: ${line.slice(0, 80)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const level = relevant.length === 0 ? 'none' : reasons.length || securityReasons.length ? 'normal' : 'low';
|
|
85
|
+
const trivial = level !== 'normal' && relevant.every((f) => !['code', 'test'].includes(fileClass(f.path)) || changeKind(f) === 'trivial');
|
|
86
|
+
return { level, trivial, reasons, security: { required: securityReasons.length > 0, reasons: securityReasons } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mergeBase(cwd, defaultBranch) {
|
|
90
|
+
for (const ref of [`origin/${defaultBranch}`, defaultBranch]) {
|
|
91
|
+
try {
|
|
92
|
+
return git(cwd, ['merge-base', 'HEAD', ref]).trim() || null;
|
|
93
|
+
} catch { /* next ref */ }
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The feature's diff from the merge-base with the default, working tree included; null when git cannot tell. */
|
|
99
|
+
export function featureDiffRisk(cwd, defaultBranch) {
|
|
100
|
+
const base = defaultBranch ? mergeBase(cwd, defaultBranch) : null;
|
|
101
|
+
if (!base) return null;
|
|
102
|
+
try {
|
|
103
|
+
return { base, ...classifyFiles(diffLines(cwd, base)) };
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The delta between two commits; `trivial` (no logic added or removed) never asks for a second review. */
|
|
110
|
+
export function deltaRisk(cwd, from, to) {
|
|
111
|
+
try {
|
|
112
|
+
return classifyFiles(diffLines(cwd, from, to));
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -214,7 +214,7 @@ const PLAN_CRITIQUE_REASONS = {
|
|
|
214
214
|
// Regra única do gate-check (pausa) e do approve/advance automáticos do plano (recusa).
|
|
215
215
|
export function planCritiquePause(status) {
|
|
216
216
|
const what = PLAN_CRITIQUE_REASONS[status];
|
|
217
|
-
return what ? `${what} —
|
|
217
|
+
return what ? `${what} — escreva a crítica antes do gate do plano: a sessão com o checklist de grep, ou o sub-agent quando o plan-check pedir (morph-plan, references/critique.md)` : null;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
// Pauses the session clears by doing the work the reason names; the rest wait for the human.
|
|
@@ -6,6 +6,7 @@ import { join } from 'path';
|
|
|
6
6
|
import { addedLinesAgainst, detectDefaultBranch } from '../../git/default-branch.js';
|
|
7
7
|
import { resolveBaseRef } from '../../git/branch-classifier.js';
|
|
8
8
|
import { isTestFile } from '../reuse/symbol-scanner.js';
|
|
9
|
+
import { readPlanChecks } from '../../plan/checks.js';
|
|
9
10
|
import { isGenerated, isProjectSource } from '../../../../framework/hooks/shared/source-files.js';
|
|
10
11
|
import { citesProcess, commentLines } from '../../../../framework/hooks/shared/comment-scanner.js';
|
|
11
12
|
|
|
@@ -14,6 +15,9 @@ export const MAX_TEST_RATIO = 1;
|
|
|
14
15
|
/** Abaixo disto a razão não é julgada: a regressão de um bug de 2 linhas cabe aqui. */
|
|
15
16
|
export const MIN_TEST_LINES = 40;
|
|
16
17
|
|
|
18
|
+
/** Linhas de teste que cada cenário com `prova: teste` (sempre com `risco:`) pode pedir além da razão. */
|
|
19
|
+
export const REQUIRED_TEST_ALLOWANCE = 40;
|
|
20
|
+
|
|
17
21
|
export const MAX_COMMENT_RATIO = 0.1;
|
|
18
22
|
|
|
19
23
|
/** Abaixo disto a densidade não é julgada: um comentário legítimo num arquivo pequeno já passaria da razão. */
|
|
@@ -48,7 +52,7 @@ export function measureCodeBudget(files) {
|
|
|
48
52
|
return measured;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
export function budgetIssues(measured) {
|
|
55
|
+
export function budgetIssues(measured, { requiredTests = 0 } = {}) {
|
|
52
56
|
const issues = [];
|
|
53
57
|
const { commentLines: comments, processLines, testLines, productionLines, commentsByFile = {} } = measured;
|
|
54
58
|
|
|
@@ -68,13 +72,15 @@ export function budgetIssues(measured) {
|
|
|
68
72
|
});
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
const allowance = REQUIRED_TEST_ALLOWANCE * requiredTests;
|
|
76
|
+
if (testLines > MIN_TEST_LINES && testLines > MAX_TEST_RATIO * productionLines + allowance) {
|
|
72
77
|
const ratio = productionLines > 0 ? ` (${decimal(testLines / productionLines)}×)` : '';
|
|
78
|
+
const required = requiredTests > 0 ? `, já descontadas ${allowance} de ${requiredTests} cenário(s) com prova: teste` : '';
|
|
73
79
|
issues.push({
|
|
74
80
|
level: 'warning',
|
|
75
81
|
message:
|
|
76
82
|
`linhas de teste adicionadas passam de ${decimal(MAX_TEST_RATIO)}× as de produção: ` +
|
|
77
|
-
`${testLines} de teste para ${productionLines} de produção${ratio}`,
|
|
83
|
+
`${testLines} de teste para ${productionLines} de produção${ratio}${required}`,
|
|
78
84
|
solution:
|
|
79
85
|
'Orçamento por task: um teste por pós-condição + um de regressão por bug, cenários do mesmo ' +
|
|
80
86
|
'comportamento numa tabela, nenhum teste que só fixa texto. Excesso justificado fica escrito na task.',
|
|
@@ -103,6 +109,12 @@ export function budgetIssues(measured) {
|
|
|
103
109
|
return issues;
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
function requiredTestCount(projectPath, featureName) {
|
|
113
|
+
if (!featureName) return 0;
|
|
114
|
+
const checks = readPlanChecks(projectPath, featureName).checks?.checks ?? [];
|
|
115
|
+
return checks.filter((c) => c.proof === 'teste').length;
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
export async function validateCodeBudget(projectPath, featureName = null, options = {}) {
|
|
107
119
|
const result = { errors: 0, warnings: 0, issues: [] };
|
|
108
120
|
|
|
@@ -138,7 +150,7 @@ export async function validateCodeBudget(projectPath, featureName = null, option
|
|
|
138
150
|
return result;
|
|
139
151
|
}
|
|
140
152
|
|
|
141
|
-
result.issues = budgetIssues(measured);
|
|
153
|
+
result.issues = budgetIssues(measured, { requiredTests: requiredTestCount(projectPath, featureName) });
|
|
142
154
|
if (unreadable.length > 0) {
|
|
143
155
|
result.issues.push({
|
|
144
156
|
level: 'warning',
|
|
@@ -60,14 +60,32 @@ export function withCpuShare(node, share) {
|
|
|
60
60
|
return { ...node, command: `${node.command} --maxWorkers=${share}` };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// The complete run still runs each suite whole; it only leaves out a suite the feature diff cannot reach.
|
|
64
|
+
// The same selection `--affected` uses, so what escalates it (migration, contract, shared code) keeps all.
|
|
65
|
+
function outOfDiffSuites(projectPath, options) {
|
|
66
|
+
if (options.filter || options.recordBaseline || options.refreshCache || options.linux || options.skipTests) return options;
|
|
67
|
+
const nodes = resolveTestNodes(projectPath).nodes;
|
|
68
|
+
const selection = selectAffected({ projectPath, nodes, deps: options._affectedDeps });
|
|
69
|
+
if (selection.scope !== 'affected') return options;
|
|
70
|
+
// A suite rooted at the project root reaches everything: only a suite with its own `cwd` is a slice.
|
|
71
|
+
const kept = new Set(nodes.filter((n) => n.e2e || n.evals || n.runner === EVAL_RUNNER || !n.cwd || n.cwd === '.').map((n) => n.id));
|
|
72
|
+
const outOfDiff = selection.skipped.filter((id) => !kept.has(id));
|
|
73
|
+
return outOfDiff.length ? { ...options, outOfDiff } : options;
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
function applyAffectedScope(projectPath, options) {
|
|
64
|
-
if (!options.affected) return options;
|
|
77
|
+
if (!options.affected) return outOfDiffSuites(projectPath, options);
|
|
65
78
|
const conflict = [['filter', '--filter'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'], ['linux', '--linux']]
|
|
66
79
|
.find(([key]) => options[key]);
|
|
67
80
|
if (conflict) throw new VerifyUsageError(`--affected escolhe os testes sozinho; não combina com ${conflict[1]}`);
|
|
68
81
|
|
|
69
82
|
const selection = selectAffected({ projectPath, nodes: resolveTestNodes(projectPath).nodes, deps: options._affectedDeps });
|
|
70
83
|
if (selection.scope === 'full') return { ...options, escalatedBecause: selection.reason };
|
|
84
|
+
// Every suite selected and none narrowed is the complete run: stamp it once, so the gate's run reuses it.
|
|
85
|
+
const all = resolveTestNodes(projectPath).nodes;
|
|
86
|
+
const whole = selection.skipped.length === 0 && selection.nodes.length === all.length
|
|
87
|
+
&& selection.nodes.every((n, i) => n.command === all[i].command);
|
|
88
|
+
if (whole) return outOfDiffSuites(projectPath, { ...options, escalatedBecause: 'a seleção afetada já é a suíte inteira' });
|
|
71
89
|
return {
|
|
72
90
|
...options,
|
|
73
91
|
selection,
|
|
@@ -106,8 +124,9 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
106
124
|
// is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
|
|
107
125
|
const resolvedPlan = resolveTestNodes(projectPath);
|
|
108
126
|
const excluded = new Set(options.excludeNodes ?? []);
|
|
127
|
+
const outOfDiff = new Set(options.outOfDiff ?? []);
|
|
109
128
|
const scopedNodes = options.selection ? options.selection.nodes : resolvedPlan.nodes;
|
|
110
|
-
const testPlan = { ...resolvedPlan, nodes: scopedNodes.filter((node) => !excluded.has(node.id)) };
|
|
129
|
+
const testPlan = { ...resolvedPlan, nodes: scopedNodes.filter((node) => !excluded.has(node.id) && !outOfDiff.has(node.id)) };
|
|
111
130
|
const filter = parseTestFilter(options.filter);
|
|
112
131
|
if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
|
|
113
132
|
const declared = testPlan.nodes.map((node) => node.id).join(', ') || '(none)';
|
|
@@ -153,6 +172,7 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
153
172
|
feedback.push(`[verify] rodada afetada, suítes: ${kept}; a completa roda uma vez antes da aprovação`);
|
|
154
173
|
}
|
|
155
174
|
if (excluded.size > 0) feedback.push(`[verify] provadas pelo CI da PR no finish: ${[...excluded].join(', ')}`);
|
|
175
|
+
if (outOfDiff.size > 0) feedback.push(`[verify] suítes fora do diff da feature: ${[...outOfDiff].join(', ')}`);
|
|
156
176
|
|
|
157
177
|
// Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
|
|
158
178
|
const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
|
|
@@ -266,6 +286,7 @@ function flakyNames(nodes, retried) {
|
|
|
266
286
|
...(nodes.tests.runs || []).filter((run) => run.status === 'pass' && retried.has(run.id)).map((run) => `tests/${run.id}`),
|
|
267
287
|
...(nodes.e2e.status === 'pass' && retried.has('e2e') ? ['e2e'] : []),
|
|
268
288
|
...(nodes.e2e.flakySpecs || []).map((spec) => `e2e: ${spec}`),
|
|
289
|
+
...(nodes.e2e.quarantinedSpecs || []).map((spec) => `e2e (quarentena): ${spec}`),
|
|
269
290
|
];
|
|
270
291
|
}
|
|
271
292
|
|
|
@@ -871,6 +892,7 @@ async function runE2eNode({
|
|
|
871
892
|
count: Number.isInteger(result.count) ? result.count : null,
|
|
872
893
|
warnings: Array.isArray(result.warnings) ? result.warnings : [],
|
|
873
894
|
flakySpecs: result.flakySpecs ?? [],
|
|
895
|
+
quarantinedSpecs: result.quarantinedSpecs ?? [],
|
|
874
896
|
},
|
|
875
897
|
stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
|
|
876
898
|
};
|
|
@@ -100,7 +100,9 @@ function selectNode(node, { changed, base, cwd, git, read }) {
|
|
|
100
100
|
const relevant = inside.filter((p) => DOTNET_SOURCE.test(p));
|
|
101
101
|
return relevant.length ? selectDotnet(node, relevant, cwd, git, read) : { skip: true };
|
|
102
102
|
}
|
|
103
|
-
|
|
103
|
+
// A generic runner (`none`, a script) tests whatever the project says it does: any code in its cwd reaches it.
|
|
104
|
+
const generic = !node.runner || node.runner === 'none';
|
|
105
|
+
const relevant = generic ? inside : inside.filter((p) => SCRIPT_SOURCE.test(p));
|
|
104
106
|
if (relevant.length === 0) return { skip: true };
|
|
105
107
|
return node.runner === 'vitest' ? selectVitest(node, base) : { node };
|
|
106
108
|
}
|