@polymorphism-tech/morph-spec 9.1.0 → 9.2.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 +7 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -0
- package/framework/MORPH.md +2 -2
- package/framework/agents.json +8 -8
- package/framework/docs/CLI.md +16 -14
- package/framework/evals/review-checklist.md +12 -2
- package/framework/hooks/README.md +8 -44
- package/framework/hooks/claude-code/core/state-sync.js +1 -1
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/rules/nextjs-standards.md +1 -1
- 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 +3 -3
- package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +8 -6
- package/framework/skills/core/morph-review/SKILL.md +9 -6
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +2 -2
- package/framework/standards/backend/dotnet/testing.md +10 -4
- package/framework/standards/frontend/nextjs/testing.md +10 -6
- package/framework/templates/.idea/morph-templates.xml +92 -0
- package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/docs/plan.md +3 -1
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
- package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/state/approve.js +2 -2
- package/src/commands/validation/mutate.js +9 -6
- package/src/commands/validation/plan-check.js +4 -2
- package/src/commands/validation/tests-audit.js +7 -0
- package/src/commands/validation/validate-feature.js +4 -2
- package/src/commands/validation/verify.js +15 -15
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/ci/workflow-scaffold.js +12 -0
- package/src/lib/e2e/applicability.js +37 -0
- package/src/lib/mutation/adapters.js +6 -17
- package/src/lib/mutation/runner.js +23 -75
- package/src/lib/mutation/trx.js +0 -1
- package/src/lib/plan/checks.js +29 -2
- package/src/lib/plan/plan-parser.js +6 -2
- package/src/lib/review-reports.js +6 -3
- package/src/lib/risk-detector.js +2 -0
- package/src/lib/tests-audit/audit.js +96 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
- package/src/lib/validators/validation-runner.js +6 -38
- package/src/lib/validators/verify-runner.js +36 -21
- package/src/utils/agents-installer.js +1 -1
- package/framework/hooks/git/commit-msg/conventional-commits.sh +0 -33
- package/framework/hooks/git/pre-commit/agents.sh +0 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +0 -64
- package/framework/hooks/git/pre-push/run-tests.sh +0 -44
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import { defineConfig } from 'vite';
|
|
2
|
-
import react from '@vitejs/plugin-react';
|
|
3
|
-
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
-
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import { dirname, resolve } from 'path';
|
|
7
|
-
|
|
8
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
-
const __dirname = dirname(__filename);
|
|
10
|
-
|
|
11
|
-
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
-
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
-
|
|
14
|
-
export default defineConfig({
|
|
15
|
-
root: __dirname,
|
|
16
|
-
plugins: [
|
|
17
|
-
react(),
|
|
18
|
-
tailwindcss(),
|
|
19
|
-
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
-
],
|
|
21
|
-
server: {
|
|
22
|
-
port: 3200,
|
|
23
|
-
},
|
|
24
|
-
});
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, resolve } from 'path';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
+
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
+
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
root: __dirname,
|
|
16
|
+
plugins: [
|
|
17
|
+
react(),
|
|
18
|
+
tailwindcss(),
|
|
19
|
+
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
+
],
|
|
21
|
+
server: {
|
|
22
|
+
port: 3200,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync } from 'fs';
|
|
|
2
2
|
import { join, posix, relative, resolve } from 'path';
|
|
3
3
|
import { PACKAGE_LOCATIONS } from '../detectors/index.js';
|
|
4
4
|
import { CI_ARTIFACT, CI_RESULT_FILE } from './github-ci.js';
|
|
5
|
+
import { discoverDotnetSolution } from '../tasks/test-runner.js';
|
|
5
6
|
|
|
6
7
|
const LOCKFILES = [
|
|
7
8
|
['package-lock.json', 'npm ci'],
|
|
@@ -50,6 +51,16 @@ function dotnetSetup(root, config) {
|
|
|
50
51
|
];
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// The verify build node runs `dotnet build --no-restore` (right on a warm tree): a fresh checkout restores first,
|
|
55
|
+
// against the same target the build node resolves.
|
|
56
|
+
function dotnetRestore(root, config) {
|
|
57
|
+
if (!usesDotnet(root, config)) return [];
|
|
58
|
+
const backend = config?.project?.backendPath;
|
|
59
|
+
if (typeof backend === 'string') return step('dotnet restore', backend, 'dotnet restore');
|
|
60
|
+
const { target } = discoverDotnetSolution(root);
|
|
61
|
+
return step('dotnet restore', '.', target && target !== '.' ? `dotnet restore "${target}"` : 'dotnet restore');
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
const step = (name, dir, run) => [
|
|
54
65
|
` - name: ${name}`,
|
|
55
66
|
...(dir === '.' ? [] : [` working-directory: ${posix.normalize(dir)}`]),
|
|
@@ -94,6 +105,7 @@ export function renderCiWorkflow(root, config) {
|
|
|
94
105
|
lines.push(...step(`install (${pkg.dir})`, pkg.dir, pkg.install));
|
|
95
106
|
if (pkg.playwright) lines.push(...step(`playwright browsers (${pkg.dir})`, pkg.dir, 'npx playwright install --with-deps chromium'));
|
|
96
107
|
}
|
|
108
|
+
lines.push(...dotnetRestore(root, config));
|
|
97
109
|
lines.push(
|
|
98
110
|
' - name: morph-spec verify',
|
|
99
111
|
' shell: bash',
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { basename } from 'path';
|
|
2
|
+
import { readPlanChecks } from '../plan/checks.js';
|
|
3
|
+
import { changedPathsAgainst, detectDefaultBranch } from '../git/default-branch.js';
|
|
4
|
+
import { resolveComposeFile } from '../../../framework/hooks/shared/e2e-check.js';
|
|
5
|
+
import { E2E_SPEC_LOCATIONS } from '../../../framework/hooks/shared/frontend-locations.js';
|
|
6
|
+
|
|
7
|
+
export const E2E_NOT_APPLICABLE = 'not applicable: no `prova: e2e` scenario and the diff touches no UI, e2e spec or container file';
|
|
8
|
+
|
|
9
|
+
const UI_FILE = /\.(tsx|jsx|vue|svelte|astro|css|scss|sass|less|html)$/i;
|
|
10
|
+
const RUNTIME_FILE = /(^|\/)(dockerfile[^/]*|\.dockerignore|(docker-)?compose[^/]*\.ya?ml|playwright\.config\.[cm]?[jt]s)$/i;
|
|
11
|
+
const E2E_DIR = /(^|\/)e2e\//i;
|
|
12
|
+
const KNOWN_PACKAGE_DIRS = E2E_SPEC_LOCATIONS.filter((dir) => dir !== '.');
|
|
13
|
+
|
|
14
|
+
function packageDirsOf(config) {
|
|
15
|
+
const declared = [config?.project?.frontendPath, ...(config?.project?.tests ?? []).filter((n) => n?.runner !== 'dotnet').map((n) => n?.cwd)];
|
|
16
|
+
const extra = declared.filter((d) => typeof d === 'string').map((d) => d.replace(/\\/g, '/').replace(/^\.\/|\/$/g, '')).filter((d) => d && d !== '.');
|
|
17
|
+
return [...KNOWN_PACKAGE_DIRS, ...extra];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const touchesRuntime = (file, composeFile, packageDirs) => UI_FILE.test(file)
|
|
21
|
+
|| RUNTIME_FILE.test(file)
|
|
22
|
+
|| E2E_DIR.test(file)
|
|
23
|
+
|| (composeFile && basename(file) === basename(composeFile))
|
|
24
|
+
|| packageDirs.some((dir) => file === dir || file.startsWith(`${dir}/`));
|
|
25
|
+
|
|
26
|
+
/** Unknown (no git, no base, empty diff) is applicable: the e2e only stands down on evidence. */
|
|
27
|
+
export function e2eApplicability(projectPath, featureName, config) {
|
|
28
|
+
const { checks } = readPlanChecks(projectPath, featureName);
|
|
29
|
+
if (checks?.checks?.some((c) => c.proof === 'e2e')) return { applicable: true, reason: 'prova: e2e' };
|
|
30
|
+
const base = detectDefaultBranch(projectPath);
|
|
31
|
+
const changed = base ? changedPathsAgainst(base, projectPath).filter((f) => !f.startsWith('.morph/')) : [];
|
|
32
|
+
if (changed.length === 0) return { applicable: true, reason: 'diff unknown' };
|
|
33
|
+
const composeFile = resolveComposeFile(projectPath, config);
|
|
34
|
+
const packageDirs = packageDirsOf(config);
|
|
35
|
+
const hit = changed.find((f) => touchesRuntime(f, composeFile, packageDirs));
|
|
36
|
+
return hit ? { applicable: true, reason: hit } : { applicable: false, reason: E2E_NOT_APPLICABLE };
|
|
37
|
+
}
|
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { dotnetBuildCommands, withFilter, withTrx, withoutBuild } from './commands.js';
|
|
5
|
-
import {
|
|
5
|
+
import { parseTrx } from './trx.js';
|
|
6
6
|
import { stripAnsi } from '../tasks/test-runner.js';
|
|
7
7
|
|
|
8
8
|
const MAX_CAPTURE = 16 * 1024 * 1024;
|
|
@@ -39,14 +39,10 @@ export function dotnetAdapter({ node, root, workDir, exec, subset, timeoutMs })
|
|
|
39
39
|
const trxDir = join(workDir, 'trx');
|
|
40
40
|
mkdirSync(trxDir, { recursive: true });
|
|
41
41
|
const base = withoutBuild(node.command);
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
scope === 'full' ? base : withFilter(base, filterOf[scope](filter)),
|
|
45
|
-
{ dir: trxDir, name: `${label}.trx` },
|
|
46
|
-
);
|
|
47
|
-
const runTests = async ({ scope, label, env, filter }) => {
|
|
42
|
+
const commandFor = (label) => withTrx(withFilter(base, subset.filter), { dir: trxDir, name: `${label}.trx` });
|
|
43
|
+
const runTests = async ({ label, env }) => {
|
|
48
44
|
const trxPath = join(trxDir, `${label}.trx`);
|
|
49
|
-
const r = await exec(commandFor(
|
|
45
|
+
const r = await exec(commandFor(label), { cwd, env, timeoutMs, label });
|
|
50
46
|
if (!existsSync(trxPath)) {
|
|
51
47
|
return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: r.timedOut ? `timeout de ${timeoutMs} ms` : `sem TRX (exit ${r.status})` };
|
|
52
48
|
}
|
|
@@ -62,12 +58,6 @@ export function dotnetAdapter({ node, root, workDir, exec, subset, timeoutMs })
|
|
|
62
58
|
buildCommands: dotnetBuildCommands(node, root),
|
|
63
59
|
cwd,
|
|
64
60
|
runTests,
|
|
65
|
-
reproduce: ({ names, label, env }) => runTests({
|
|
66
|
-
scope: 'names',
|
|
67
|
-
label,
|
|
68
|
-
env,
|
|
69
|
-
filter: [...new Set(names.map(methodOf))].map((n) => `FullyQualifiedName~${n}`).join('|'),
|
|
70
|
-
}),
|
|
71
61
|
};
|
|
72
62
|
}
|
|
73
63
|
|
|
@@ -78,9 +68,8 @@ export function genericAdapter({ node, root, exec, subset, timeoutMs }) {
|
|
|
78
68
|
hasSubset: Boolean(subset?.usable),
|
|
79
69
|
buildCommands: [],
|
|
80
70
|
cwd,
|
|
81
|
-
async runTests({
|
|
82
|
-
const
|
|
83
|
-
const r = await exec(command, { cwd, env, timeoutMs, label });
|
|
71
|
+
async runTests({ label, env }) {
|
|
72
|
+
const r = await exec(subset.command, { cwd, env, timeoutMs, label });
|
|
84
73
|
if (r.timedOut) return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: `timeout de ${timeoutMs} ms` };
|
|
85
74
|
if (r.status === 0) return { outcome: 'green', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs };
|
|
86
75
|
const failedTests = genericFailures(`${r.stdout}\n${r.stderr}`);
|
|
@@ -4,12 +4,13 @@ import { applyRegions, diffOf } from './manifest.js';
|
|
|
4
4
|
import { blame, parseCompilerErrors, patchFile, switchClassName, switchSource, SWITCH_ENV } from './schemata.js';
|
|
5
5
|
import { md5Of, md5OfFile, openSnapshot, restoreSnapshot, trackGenerated } from './snapshot.js';
|
|
6
6
|
import { parseBuiltDlls } from './commands.js';
|
|
7
|
-
import { firstLines
|
|
7
|
+
import { firstLines } from './trx.js';
|
|
8
8
|
import { nearestProjectOf } from './dotnet-introspection.js';
|
|
9
9
|
|
|
10
10
|
const posix = (p) => p.replace(/\\/g, '/');
|
|
11
11
|
const MAX_SCHEMATA_ATTEMPTS = 3;
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
export const noSubsetReason = (why) => `sem subconjunto utilizável (${why ?? 'nenhum teste da feature nem que cite os tipos mutados'}): a mutação não roda a suíte inteira — prove o cenário por leitura`;
|
|
13
14
|
|
|
14
15
|
const envWithout = (env) => { const { [SWITCH_ENV]: _drop, ...rest } = env; return rest; };
|
|
15
16
|
const failedNames = (result) => new Set(result.failedTests.map((t) => t.name));
|
|
@@ -123,29 +124,15 @@ export async function runMutation(input) {
|
|
|
123
124
|
.map((name) => [name, md5OfFile(dlls.get(name))]),
|
|
124
125
|
);
|
|
125
126
|
|
|
126
|
-
const run = (
|
|
127
|
-
|
|
128
|
-
const confirmKill = async (id, verdict, env) => {
|
|
129
|
-
if (!adapter.reproduce) return verdict;
|
|
130
|
-
const names = [...new Set(verdict.failures.map((f) => f.name))].slice(0, MAX_REPRODUCE);
|
|
131
|
-
const again = await timed(`${id}-reproduce`, () => adapter.reproduce({ names, label: `${id}-reproduce`, env }));
|
|
132
|
-
if (again.outcome === 'crash' || !again.executed) {
|
|
133
|
-
return { state: 'inconclusive', reason: `a falha de ${names[0]} não pôde ser confirmada na repetição (${again.note ?? 'nenhum teste executado'})` };
|
|
134
|
-
}
|
|
135
|
-
const wanted = new Set(names.map(methodOf));
|
|
136
|
-
const held = again.failedTests.filter((t) => wanted.has(methodOf(t.name)));
|
|
137
|
-
if (held.length > 0) return { ...verdict, reason: reasonOf(held) };
|
|
138
|
-
const whole = await timed(`${id}-reproduce-full`, () => adapter.runTests({ scope: 'full', label: `${id}-reproduce-full`, env }));
|
|
139
|
-
if (whole.outcome === 'crash' || !whole.executed) {
|
|
140
|
-
return { state: 'inconclusive', reason: `a falha de ${names[0]} não pôde ser confirmada na repetição (${whole.note ?? 'nenhum teste executado'})` };
|
|
141
|
-
}
|
|
142
|
-
const heldWhole = whole.failedTests.filter((t) => wanted.has(methodOf(t.name)));
|
|
143
|
-
if (heldWhole.length > 0) return { ...verdict, reason: reasonOf(heldWhole) };
|
|
144
|
-
const which = names.length === 1 ? names[0] : `${names.length} testes`;
|
|
145
|
-
return { state: 'survived', reason: `nenhum teste falhou de forma reproduzível: ${which} falhou uma vez e passou isolado e na suíte inteira repetida (instável, não prova o mutante)` };
|
|
146
|
-
};
|
|
127
|
+
const run = (label, env) => timed(label, () => adapter.runTests({ label, env }));
|
|
147
128
|
|
|
148
129
|
const record = (id, patch) => Object.assign(results.get(id), patch);
|
|
130
|
+
const recordSubset = (id, result) => {
|
|
131
|
+
const verdict = judge(result, baselineSubset);
|
|
132
|
+
const base = { scope: 'subset', executed: result.executed, durationMs: result.durationMs };
|
|
133
|
+
if (verdict.state === 'killed') record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
|
|
134
|
+
else record(id, { ...base, verdict: verdict.state, reason: verdict.reason ?? '' });
|
|
135
|
+
};
|
|
149
136
|
const applyOnDisk = (id) => {
|
|
150
137
|
const region = byId.get(id);
|
|
151
138
|
writeFileSync(join(root, region.file), applyRegions(originals.get(region.file), [region], (x) => x.replaceText).text);
|
|
@@ -154,10 +141,8 @@ export async function runMutation(input) {
|
|
|
154
141
|
let pristine = new Map();
|
|
155
142
|
let binary = { proof: 'unavailable', projects: [] };
|
|
156
143
|
let baselineSubset = null;
|
|
157
|
-
let baselineFull = null;
|
|
158
144
|
let subsetLive = adapter.hasSubset;
|
|
159
145
|
let subsetNote = null;
|
|
160
|
-
const needFullBaseline = [];
|
|
161
146
|
let restoreReport = [];
|
|
162
147
|
|
|
163
148
|
try {
|
|
@@ -238,40 +223,26 @@ export async function runMutation(input) {
|
|
|
238
223
|
if (subsetLive && (schemataIds.length > 0 || hasRebuild)) {
|
|
239
224
|
const copies = schemataIds.length > 0 ? jobs : 1;
|
|
240
225
|
baselineSubset = mergeBaselines(await Promise.all(Array.from({ length: copies }, (_, i) => (
|
|
241
|
-
run(
|
|
226
|
+
run(i === 0 ? 'baseline-subset' : `baseline-subset-${i + 1}`, envWithout(process.env))))));
|
|
242
227
|
if (baselineSubset.outcome === 'crash' || baselineSubset.executed === 0) {
|
|
243
228
|
subsetLive = false;
|
|
244
229
|
subsetNote = baselineSubset.executed === 0 ? 'o filtro do subconjunto não selecionou teste algum' : (baselineSubset.note ?? 'a rodada-base do subconjunto não terminou');
|
|
245
230
|
}
|
|
246
231
|
}
|
|
247
|
-
if (!subsetLive && schemataIds.length > 0) baselineFull = await run('full', 'baseline-full', envWithout(process.env));
|
|
248
|
-
|
|
249
|
-
const scopeOf = subsetLive ? 'subset' : 'full';
|
|
250
|
-
const scopeBaseline = () => (subsetLive ? baselineSubset : baselineFull);
|
|
251
232
|
const routed = [];
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
const base = { scope: scopeOf, executed: result.executed, durationMs: result.durationMs };
|
|
263
|
-
if (verdict.state === 'killed') {
|
|
264
|
-
record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
|
|
265
|
-
} else if (scopeOf === 'subset' && verdict.state === 'survived') {
|
|
266
|
-
record(id, { ...base, verdict: 'survived', reason: '' });
|
|
267
|
-
} else {
|
|
268
|
-
record(id, { ...base, verdict: null, reason: verdict.reason ?? '' });
|
|
269
|
-
routed.push({ id, needs: 'full' });
|
|
270
|
-
}
|
|
233
|
+
workerCount = subsetLive && schemataIds.length > 0 ? jobs : 1;
|
|
234
|
+
if (subsetLive) {
|
|
235
|
+
const outcomes = new Map();
|
|
236
|
+
await pool(schemataIds, jobs, async (id) => {
|
|
237
|
+
outcomes.set(id, await run(`${id}-subset`, { ...process.env, [SWITCH_ENV]: id }));
|
|
238
|
+
});
|
|
239
|
+
for (const id of schemataIds) recordSubset(id, outcomes.get(id));
|
|
240
|
+
for (const r of results.values()) if (r.mode === 'rebuild') routed.push(r.id);
|
|
241
|
+
} else {
|
|
242
|
+
for (const r of results.values()) record(r.id, { verdict: 'inconclusive', scope: null, reason: noSubsetReason(subsetNote) });
|
|
271
243
|
}
|
|
272
|
-
for (const r of results.values()) if (r.mode === 'rebuild') routed.push({ id: r.id, needs: subsetLive ? 'subset' : 'full' });
|
|
273
244
|
|
|
274
|
-
for (const
|
|
245
|
+
for (const id of routed) {
|
|
275
246
|
applyOnDisk(id);
|
|
276
247
|
try {
|
|
277
248
|
if (adapter.buildCommands.length > 0) {
|
|
@@ -282,21 +253,7 @@ export async function runMutation(input) {
|
|
|
282
253
|
continue;
|
|
283
254
|
}
|
|
284
255
|
}
|
|
285
|
-
|
|
286
|
-
const sub = await run('subset', `${id}-subset`, envWithout(process.env));
|
|
287
|
-
const verdict = judge(sub, baselineSubset);
|
|
288
|
-
const base = { scope: 'subset', executed: sub.executed, durationMs: sub.durationMs };
|
|
289
|
-
if (verdict.state === 'killed') record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
|
|
290
|
-
else record(id, { ...base, verdict: verdict.state, reason: verdict.reason ?? '' });
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
const full = await run('full', `${id}-full`, envWithout(process.env));
|
|
294
|
-
const first = judge(full, baselineFull);
|
|
295
|
-
const verdict = first.state === 'killed' ? await confirmKill(id, first, envWithout(process.env)) : first;
|
|
296
|
-
const base = { scope: 'full', executed: full.executed, durationMs: full.durationMs };
|
|
297
|
-
if (verdict.state === 'inconclusive') record(id, { ...base, verdict: 'inconclusive', reason: verdict.reason });
|
|
298
|
-
else if (verdict.state === 'survived') record(id, { ...base, verdict: 'survived', reason: verdict.reason ?? '' });
|
|
299
|
-
else { record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason }); if (!baselineFull) needFullBaseline.push({ id, full }); }
|
|
256
|
+
recordSubset(id, await run(`${id}-subset`, envWithout(process.env)));
|
|
300
257
|
} finally {
|
|
301
258
|
restoreSnapshot(snapshot);
|
|
302
259
|
}
|
|
@@ -310,14 +267,6 @@ export async function runMutation(input) {
|
|
|
310
267
|
binary = proveBinary({ pristine, dlls: last.dlls, restoredAt, hashes: dllHashes(last.dlls) });
|
|
311
268
|
}
|
|
312
269
|
|
|
313
|
-
if (needFullBaseline.length > 0) {
|
|
314
|
-
baselineFull = await run('full', 'baseline-full', envWithout(process.env));
|
|
315
|
-
for (const { id, full } of needFullBaseline) {
|
|
316
|
-
const verdict = judge(full, baselineFull);
|
|
317
|
-
if (verdict.state === 'killed') record(id, { killers: verdict.failures.length, reason: verdict.reason });
|
|
318
|
-
else record(id, { verdict: 'survived', killers: 0, reason: '' });
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
270
|
} finally {
|
|
322
271
|
restoreReport = restoreSnapshot(snapshot, { rewrite: false });
|
|
323
272
|
gate?.release();
|
|
@@ -338,7 +287,6 @@ export async function runMutation(input) {
|
|
|
338
287
|
mutants,
|
|
339
288
|
baseline: {
|
|
340
289
|
subset: baselineSubset ? { executed: baselineSubset.executed, failed: baselineSubset.failed } : null,
|
|
341
|
-
full: baselineFull ? { executed: baselineFull.executed, failed: baselineFull.failed } : null,
|
|
342
290
|
},
|
|
343
291
|
binary,
|
|
344
292
|
restore: {
|
package/src/lib/mutation/trx.js
CHANGED
|
@@ -43,7 +43,6 @@ export function parseTrx(xml) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export const methodOf = (testName) => String(testName).replace(/\s*\(.*$/s, '').trim();
|
|
47
46
|
|
|
48
47
|
export function firstLines(message, max = 2, width = 240) {
|
|
49
48
|
const lines = String(message).split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, max).join(' ⏎ ');
|
package/src/lib/plan/checks.js
CHANGED
|
@@ -3,8 +3,9 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
3
3
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
4
4
|
import { frozenBody } from '../../../framework/hooks/shared/errata.js';
|
|
5
5
|
import { readAgentsJson } from '../specs/screen-checklist.js';
|
|
6
|
+
import { getFeature } from '../../../framework/hooks/shared/state-reader.js';
|
|
6
7
|
import { findArtifactTerms } from './artifact-vocabulary.js';
|
|
7
|
-
import { PROOF_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
|
|
8
|
+
import { PROOF_KINDS, RISK_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
|
|
8
9
|
|
|
9
10
|
export const CHECKS_VERSION = 1;
|
|
10
11
|
export const MAX_SLICE_FILES = 8;
|
|
@@ -83,7 +84,19 @@ export function parallelGroups(slices) {
|
|
|
83
84
|
return groups;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
// A frozen body already extracted under older rules cannot gain a `risco:` line, so it is not demanded.
|
|
88
|
+
export function planRuleOptions(cwd, featureName) {
|
|
89
|
+
return {
|
|
90
|
+
workType: getFeature(featureName, cwd)?.workType ?? null,
|
|
91
|
+
riskRules: readPlanChecks(cwd, featureName).status !== 'fresh',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const foldRisk = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/`/g, '').toLowerCase().trim();
|
|
96
|
+
const RISK_BY_FOLD = new Map(RISK_KINDS.map((r) => [foldRisk(r), r]));
|
|
97
|
+
const REGRESSION_WORK_TYPES = ['bug', 'hotfix'];
|
|
98
|
+
|
|
99
|
+
export function validatePlan(plan, { personas = null, surfaces = null, workType = null, riskRules = true } = {}) {
|
|
87
100
|
const errors = [];
|
|
88
101
|
const warnings = [];
|
|
89
102
|
const at = (line) => (line ? ` (linha ${line})` : '');
|
|
@@ -114,12 +127,26 @@ export function validatePlan(plan, { personas = null, surfaces = null } = {}) {
|
|
|
114
127
|
} else if (!PROOF_KINDS.includes(c.proof.toLowerCase())) {
|
|
115
128
|
errors.push(`${c.id}: prova "${c.proof}" fora de ${PROOF_KINDS.join(' | ')}${at(c.line)}`);
|
|
116
129
|
}
|
|
130
|
+
const proof = c.proof?.toLowerCase();
|
|
131
|
+
const risk = c.risk === null ? null : RISK_BY_FOLD.get(foldRisk(c.risk)) ?? false;
|
|
132
|
+
if (risk === false) {
|
|
133
|
+
errors.push(`${c.id}: risco "${c.risk}" fora de ${RISK_KINDS.join(' | ')}${at(c.line)}`);
|
|
134
|
+
} else if (riskRules && proof === 'teste' && risk === null) {
|
|
135
|
+
errors.push(`${c.id}: "prova: teste" sem "risco:" (${RISK_KINDS.join(' | ')}) — sem risco, a prova é "leitura" ou "e2e"${at(c.line)}`);
|
|
136
|
+
} else if (risk && proof === 'leitura') {
|
|
137
|
+
warnings.push(`${c.id}: risco "${risk}" provado só por leitura${at(c.line)}`);
|
|
138
|
+
}
|
|
117
139
|
const terms = findArtifactTerms(c.text);
|
|
118
140
|
if (terms.length > 0 && !c.artifactWhy) {
|
|
119
141
|
errors.push(`${c.id} descreve artefato ("${terms.join(', ')}") sem "> Por que artefato:" — reescreva como efeito observável ou justifique${at(c.line)}`);
|
|
120
142
|
}
|
|
121
143
|
}
|
|
122
144
|
|
|
145
|
+
const isRegression = (c) => ['teste', 'e2e'].includes(c.proof?.toLowerCase()) && c.risk !== null && RISK_BY_FOLD.get(foldRisk(c.risk)) === 'regressão';
|
|
146
|
+
if (riskRules && REGRESSION_WORK_TYPES.includes(workType) && plan.scenarios.length > 0 && !plan.scenarios.some(isRegression)) {
|
|
147
|
+
errors.push(`${workType} sem cenário de regressão — o sintoma do bug real vira um Cn com "prova: teste" e "risco: regressão"`);
|
|
148
|
+
}
|
|
149
|
+
|
|
123
150
|
const scenarioIds = new Set(plan.scenarios.map((c) => c.id));
|
|
124
151
|
const sliceIds = new Set(plan.slices.map((s) => s.id));
|
|
125
152
|
const covered = new Set();
|
|
@@ -2,7 +2,8 @@ import { tableCells, isTableLine, SEPARATOR_ROW_RE } from '../specs/gfm-table.js
|
|
|
2
2
|
import { ARTIFACT_MARKER_RE } from './artifact-vocabulary.js';
|
|
3
3
|
import { markdownLines } from '../../../framework/hooks/shared/errata.js';
|
|
4
4
|
|
|
5
|
-
export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual'];
|
|
5
|
+
export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual', 'leitura'];
|
|
6
|
+
export const RISK_KINDS = ['regra', 'dinheiro', 'contrato', 'concorrência', 'dado', 'regressão'];
|
|
6
7
|
export const REUSE_VERDICTS = ['REUSAR', 'ESTENDER', 'CRIAR'];
|
|
7
8
|
export const ADR_SCOPES = ['feature', 'projeto'];
|
|
8
9
|
|
|
@@ -36,6 +37,7 @@ const SCENARIO_RE = new RegExp(String.raw`^[-*]\s+\*\*(C\d+)\*\*${DASH}(.+)$`);
|
|
|
36
37
|
const ADR_RE = new RegExp(String.raw`^###\s+(ADR-\d+)${DASH}(.+?)\s*$`);
|
|
37
38
|
const SLICE_RE = new RegExp(String.raw`^###\s+(F\d+)${DASH}(.+?)\s*$`);
|
|
38
39
|
const PROOF_RE = /^\s*prova\s*:\s*(.*?)\s*$/i;
|
|
40
|
+
const RISK_RE = /^\s*risco\s*:\s*(.*?)\s*$/i;
|
|
39
41
|
const WHY_RE = new RegExp(String.raw`^\s*>\s*` + ARTIFACT_MARKER_RE.source + String.raw`\s*(.*?)\s*$`, 'i');
|
|
40
42
|
const SCOPE_RE = /^\s*\*\*Alcance:\*\*\s*(.*?)\s*$/i;
|
|
41
43
|
const SLICE_LINE_RE = /^\s*[-*]\s+([\p{L}]+)\s*:\s*(.*?)\s*$/u;
|
|
@@ -69,7 +71,7 @@ function parseScenarios(lines) {
|
|
|
69
71
|
if (line.fenced) { current = null; continue; }
|
|
70
72
|
const m = SCENARIO_RE.exec(line.raw);
|
|
71
73
|
if (m) {
|
|
72
|
-
current = { id: m[1], text: m[2].trim(), proof: null, artifactWhy: null, line: line.n };
|
|
74
|
+
current = { id: m[1], text: m[2].trim(), proof: null, risk: null, artifactWhy: null, line: line.n };
|
|
73
75
|
scenarios.push(current);
|
|
74
76
|
continue;
|
|
75
77
|
}
|
|
@@ -79,6 +81,8 @@ function parseScenarios(lines) {
|
|
|
79
81
|
if (why) { current.artifactWhy = why[1] || ''; continue; }
|
|
80
82
|
const proof = PROOF_RE.exec(line.raw);
|
|
81
83
|
if (proof && current.proof === null) { current.proof = proof[1]; continue; }
|
|
84
|
+
const risk = RISK_RE.exec(line.raw);
|
|
85
|
+
if (risk && current.risk === null) { current.risk = risk[1]; continue; }
|
|
82
86
|
if (/^\s*>/.test(line.raw)) continue;
|
|
83
87
|
current.text = `${current.text} ${line.raw.trim()}`;
|
|
84
88
|
}
|
|
@@ -27,10 +27,11 @@ const CHECKS_SECTION_RE = sectionRe('Checks');
|
|
|
27
27
|
const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
|
|
28
28
|
const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
|
|
29
29
|
|
|
30
|
-
/** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. */
|
|
30
|
+
/** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Chore: só o revisor (review leve). */
|
|
31
31
|
export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
|
|
32
32
|
if (workType === 'hotfix') return ['verifier'];
|
|
33
|
-
|
|
33
|
+
const base = workType === 'chore' ? ['reviewer'] : ['verifier', 'reviewer'];
|
|
34
|
+
return securityRequired ? [...base, 'security'] : base;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function sectionBodies(text, re) {
|
|
@@ -172,7 +173,9 @@ export function reviewSignal(reports, { required, checkIds }) {
|
|
|
172
173
|
superseded: r.superseded,
|
|
173
174
|
};
|
|
174
175
|
}
|
|
175
|
-
out.checks =
|
|
176
|
+
out.checks = !required.includes('verifier')
|
|
177
|
+
? { status: 'not-required', missing: [], notProven: [] }
|
|
178
|
+
: checkIds === null
|
|
176
179
|
? { status: 'no-checks', missing: [], notProven: [] }
|
|
177
180
|
: { status: 'ok', total: checkIds.length, ...checksCoverage(checkIds, reports.verifier.checks) };
|
|
178
181
|
return out;
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -11,6 +11,7 @@ const E2E_SKIP_PATTERNS = [
|
|
|
11
11
|
[/docker is not available/i, 'environment'],
|
|
12
12
|
[/^requested$/i, 'requested'],
|
|
13
13
|
[/does not containerize/i, 'project'],
|
|
14
|
+
[/^not applicable:/i, 'not-applicable'],
|
|
14
15
|
];
|
|
15
16
|
|
|
16
17
|
// `unknown` (razão vazia) pausa de propósito: dispensa sem motivo é o no-op silencioso que a regra elimina.
|
|
@@ -99,6 +100,7 @@ export function reviewPause(signal) {
|
|
|
99
100
|
if (r.verdict !== 'SHIP') return `veredito do ${role} é ${r.verdict}`;
|
|
100
101
|
}
|
|
101
102
|
const checks = signal?.checks;
|
|
103
|
+
if (checks?.status === 'not-required') return null;
|
|
102
104
|
if (!checks || checks.status !== 'ok') {
|
|
103
105
|
return 'sem `2-plan/checks.json` legível — a cobertura dos cenários pelo verificador não é conferível';
|
|
104
106
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { basename, join } from 'path';
|
|
4
|
+
import { isTestPath } from '../graph/sanitize.js';
|
|
5
|
+
|
|
6
|
+
export const PROSE_MIN_HITS = 3;
|
|
7
|
+
export const FAMILY_MIN_SIZE = 4;
|
|
8
|
+
|
|
9
|
+
const TEST_FILE = /\.(cs|[cm]?[jt]sx?)$/i;
|
|
10
|
+
const CS_CASE = /\[\s*\w*(Fact|Theory)\b/g;
|
|
11
|
+
const JS_CASE = /^\s*(?:it|test)(?:\.each\s*\([\s\S]*?\))?\s*\(\s*['"`]/gm;
|
|
12
|
+
const READS_DOC = /(ReadAll\w*|readFileSync)\s*\([^)]*\.(md|txt)\b/;
|
|
13
|
+
const PROSE_ASSERT = /(?:Assert\.(?:Contains|DoesNotContain)\(\s*"|\.(?:toContain|toMatch)\(\s*['"`])([^"'`]{20,})/g;
|
|
14
|
+
const CONSTANT_ASSERT = /Assert\.Equal\(\s*"[^"]*"\s*,\s*[A-Z]\w*(?:\.[A-Z]\w*)+\s*\)|expect\(\s*[A-Z][A-Z0-9_]+(?:\.[A-Z0-9_]+)*\s*\)\.toBe\(\s*['"`]/g;
|
|
15
|
+
const CS_METHOD = /\[\s*\w*Fact\b[^\]]*\][\s\S]*?(?:void|Task)\s+(\w+)\s*\(/g;
|
|
16
|
+
const LAYER = /^(\w+?)(Handler|Endpoints?|EndpointIntegration|Integration)Tests$/;
|
|
17
|
+
|
|
18
|
+
function trackedTestFiles(root) {
|
|
19
|
+
try {
|
|
20
|
+
return execFileSync('git', ['ls-files', '-z'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024 })
|
|
21
|
+
.toString().split('\0').filter((f) => f && TEST_FILE.test(f) && isTestPath(f));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
28
|
+
const countOf = (text, re) => (text.match(re) ?? []).length;
|
|
29
|
+
|
|
30
|
+
function factFamilies(text) {
|
|
31
|
+
const byStem = new Map();
|
|
32
|
+
for (const m of text.matchAll(CS_METHOD)) {
|
|
33
|
+
const name = m[1];
|
|
34
|
+
const cut = name.lastIndexOf('_');
|
|
35
|
+
if (cut <= 0) continue;
|
|
36
|
+
const stem = name.slice(0, cut);
|
|
37
|
+
byStem.set(stem, [...(byStem.get(stem) ?? []), name]);
|
|
38
|
+
}
|
|
39
|
+
return [...byStem].filter(([, names]) => names.length >= FAMILY_MIN_SIZE).map(([stem, names]) => ({ stem, size: names.length }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read-only heuristics over the tracked test files: candidates to delete or merge, never verdicts. */
|
|
43
|
+
export function auditTests(root) {
|
|
44
|
+
const files = trackedTestFiles(root);
|
|
45
|
+
if (files === null) return { ok: false, reason: 'git ls-files não respondeu — a auditoria lê só arquivos rastreados' };
|
|
46
|
+
|
|
47
|
+
const perFile = [];
|
|
48
|
+
const prose = [];
|
|
49
|
+
const constants = [];
|
|
50
|
+
const families = [];
|
|
51
|
+
const layers = new Map();
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
let text;
|
|
54
|
+
try { text = readFileSync(join(root, file), 'utf8'); } catch { continue; }
|
|
55
|
+
const cases = /\.cs$/i.test(file) ? countOf(text, CS_CASE) : countOf(text, JS_CASE);
|
|
56
|
+
if (cases === 0) continue;
|
|
57
|
+
perFile.push({ file, cases });
|
|
58
|
+
|
|
59
|
+
const proseHits = [...text.matchAll(PROSE_ASSERT)].filter((m) => /\s/.test(m[1]));
|
|
60
|
+
if (READS_DOC.test(text) || proseHits.length >= PROSE_MIN_HITS) {
|
|
61
|
+
prose.push({ file, readsDoc: READS_DOC.test(text), literalAsserts: proseHits.length });
|
|
62
|
+
}
|
|
63
|
+
for (const m of text.matchAll(CONSTANT_ASSERT)) constants.push({ file, line: lineOf(text, m.index) });
|
|
64
|
+
for (const family of factFamilies(text)) families.push({ file, ...family });
|
|
65
|
+
|
|
66
|
+
const layer = LAYER.exec(basename(file).replace(/\.\w+$/, ''));
|
|
67
|
+
if (layer) layers.set(layer[1], [...(layers.get(layer[1]) ?? []), file]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
perFile.sort((a, b) => b.cases - a.cases);
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
files: perFile.length,
|
|
74
|
+
cases: perFile.reduce((n, f) => n + f.cases, 0),
|
|
75
|
+
largest: perFile.slice(0, 10),
|
|
76
|
+
prose,
|
|
77
|
+
constants,
|
|
78
|
+
families,
|
|
79
|
+
layers: [...layers].filter(([, f]) => f.length > 1).map(([subject, f]) => ({ subject, files: f })),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderTestsAudit(result) {
|
|
84
|
+
if (!result.ok) return `tests audit: ${result.reason}`;
|
|
85
|
+
const out = [`${result.cases} casos em ${result.files} arquivos de teste`, ''];
|
|
86
|
+
const section = (title, rows) => {
|
|
87
|
+
if (rows.length === 0) return;
|
|
88
|
+
out.push(`${title} (${rows.length})`, ...rows.map((r) => ` ${r}`), '');
|
|
89
|
+
};
|
|
90
|
+
section('Maiores arquivos', result.largest.map((f) => `${f.cases} ${f.file}`));
|
|
91
|
+
section('Fixam texto ou leem doc — candidatos a apagar', result.prose.map((p) => `${p.file}${p.readsDoc ? ' (lê .md/.txt)' : ''}${p.literalAsserts ? ` — ${p.literalAsserts} asserts de frase` : ''}`));
|
|
92
|
+
section('Constante contra o próprio literal — candidatos a apagar', result.constants.map((c) => `${c.file}:${c.line}`));
|
|
93
|
+
section(`Famílias de [Fact] (≥ ${FAMILY_MIN_SIZE} com o mesmo prefixo) — candidatas a [Theory]`, result.families.map((f) => `${f.file} — ${f.stem}_* ×${f.size}`));
|
|
94
|
+
section('Mesmo assunto em mais de uma camada — mantenha a mais barata que morde', result.layers.map((l) => `${l.subject}: ${l.files.join(', ')}`));
|
|
95
|
+
return out.join('\n').trimEnd();
|
|
96
|
+
}
|
|
@@ -9,7 +9,7 @@ import { isTestFile } from '../reuse/symbol-scanner.js';
|
|
|
9
9
|
import { isGenerated, isProjectSource } from '../../../../framework/hooks/shared/source-files.js';
|
|
10
10
|
import { citesProcess, commentLines } from '../../../../framework/hooks/shared/comment-scanner.js';
|
|
11
11
|
|
|
12
|
-
export const MAX_TEST_RATIO = 1
|
|
12
|
+
export const MAX_TEST_RATIO = 1;
|
|
13
13
|
|
|
14
14
|
/** Abaixo disto a razão não é julgada: a regressão de um bug de 2 linhas cabe aqui. */
|
|
15
15
|
export const MIN_TEST_LINES = 40;
|