@polymorphism-tech/morph-spec 9.6.0 → 9.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/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +13 -6
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +3 -3
- package/framework/docs/CLI.md +53 -16
- package/framework/evals/review-checklist.md +5 -5
- package/framework/hooks/claude-code/core/README.md +4 -0
- package/framework/hooks/claude-code/core/test-budget.js +89 -0
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/heavy-command.js +60 -1
- package/framework/hooks/shared/heavy-slots.js +41 -4
- package/framework/hooks/shared/host-services.js +433 -0
- package/framework/schemas/config.schema.json +54 -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 +2 -2
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- 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 +3 -3
- package/framework/skills/core/morph-review/SKILL.md +3 -2
- 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 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +14 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +47 -24
- package/src/commands/observability/cost.js +12 -0
- package/src/commands/project/worktree.js +31 -5
- package/src/commands/state/advance.js +3 -0
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/delete.js +1 -1
- package/src/commands/state/finish.js +46 -2
- package/src/commands/state/report-check.js +36 -1
- package/src/commands/validation/plan-check.js +1 -0
- package/src/commands/validation/verify.js +3 -0
- package/src/lib/e2e/stack-teardown.js +19 -4
- package/src/lib/heavy/gate.js +22 -1
- package/src/lib/review-reports.js +23 -10
- package/src/lib/tasks/test-nodes.js +24 -6
- package/src/lib/tasks/test-runner.js +43 -1
- package/src/lib/telemetry/metrics.js +47 -3
- package/src/lib/validators/packages/package-validator.js +2 -2
- package/src/lib/validators/verify-runner.js +50 -12
- package/src/utils/agents-installer.js +5 -2
- package/src/utils/file-copier.js +26 -4
- package/src/utils/hooks-installer.js +4 -4
|
@@ -13,12 +13,18 @@ import {
|
|
|
13
13
|
resolveComposeFile,
|
|
14
14
|
tearDownStack,
|
|
15
15
|
} from '../../../framework/hooks/shared/e2e-check.js';
|
|
16
|
+
import { stopHostServices } from '../../../framework/hooks/shared/host-services.js';
|
|
16
17
|
import { buildComposeEnv } from './compose-env.js';
|
|
17
18
|
|
|
18
|
-
export function tearDownFeatureStack(feature, { root, worktreePath = null, volumes = true, _exec = defaultExec } = {}) {
|
|
19
|
+
export function tearDownFeatureStack(feature, { root, worktreePath = null, volumes = true, images, _exec = defaultExec } = {}) {
|
|
19
20
|
const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : root;
|
|
20
21
|
const projectName = composeProjectName(feature);
|
|
21
22
|
const gitCommonDir = resolveGitCommonDir(root);
|
|
23
|
+
// Before the compose lookup: a worktree already gone still leaves its host processes running.
|
|
24
|
+
const stopped = stopHostServicesSafely(gitCommonDir, projectName);
|
|
25
|
+
const hostServices = stopped.killed.length + stopped.skipped.length + stopped.warnings.length > 0
|
|
26
|
+
? { hostServices: stopped }
|
|
27
|
+
: {};
|
|
22
28
|
|
|
23
29
|
try {
|
|
24
30
|
const config = readConfig(cwd) ?? readConfig(root);
|
|
@@ -33,25 +39,34 @@ export function tearDownFeatureStack(feature, { root, worktreePath = null, volum
|
|
|
33
39
|
|
|
34
40
|
if (!composeFile) {
|
|
35
41
|
// Nothing to bring down, but a stamp left behind would tell the next `up` a stack it cannot see is fresh.
|
|
36
|
-
return { attempted: false, cwd, status: 'skip', stampDeleted: deleteStamp(gitCommonDir, projectName) };
|
|
42
|
+
return { attempted: false, cwd, status: 'skip', stampDeleted: deleteStamp(gitCommonDir, projectName), ...hostServices };
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
const env = {
|
|
40
46
|
COMPOSE_PROJECT_NAME: projectName,
|
|
41
47
|
...buildComposeEnv({ cwd: composeCwd, config, featureName: feature, gitCommonDir }),
|
|
42
48
|
};
|
|
43
|
-
const result = tearDownStack(composeCwd, { composeFile, projectName, env, volumes }, _exec);
|
|
49
|
+
const result = tearDownStack(composeCwd, { composeFile, projectName, env, volumes, images }, _exec);
|
|
44
50
|
|
|
45
51
|
return {
|
|
46
52
|
attempted: true,
|
|
47
53
|
cwd: composeCwd,
|
|
48
54
|
status: result.status,
|
|
49
55
|
stampDeleted: deleteStamp(gitCommonDir, projectName),
|
|
56
|
+
...hostServices,
|
|
50
57
|
...(result.status === 'fail' && result.output ? { output: result.output } : {}),
|
|
51
58
|
};
|
|
52
59
|
} catch {
|
|
53
60
|
// The stamp must not outlive the stack it described, even when the teardown itself blew up.
|
|
54
|
-
return { attempted: false, cwd, status: 'fail', stampDeleted: deleteStamp(gitCommonDir, projectName) };
|
|
61
|
+
return { attempted: false, cwd, status: 'fail', stampDeleted: deleteStamp(gitCommonDir, projectName), ...hostServices };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stopHostServicesSafely(gitCommonDir, projectName) {
|
|
66
|
+
try {
|
|
67
|
+
return stopHostServices(gitCommonDir, projectName);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { killed: [], skipped: [], warnings: [`host services not stopped: ${err?.message ?? err}`] };
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
|
package/src/lib/heavy/gate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
2
2
|
import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
|
|
3
3
|
import {
|
|
4
|
-
acquireHeavy, awaitFreeMemory, describeBusy, heavyDir, releaseHeavy, updateLease,
|
|
4
|
+
acquireHeavy, awaitFreeMemory, describeBusy, enterVerifySuite, heavyDir, isVerifySuite, releaseHeavy, updateLease,
|
|
5
5
|
} from '../../../framework/hooks/shared/heavy-slots.js';
|
|
6
6
|
|
|
7
7
|
export class HeavyBusyError extends Error {
|
|
@@ -33,6 +33,7 @@ export function createHeavyGate({
|
|
|
33
33
|
let lease = null;
|
|
34
34
|
let admittedGb = 0;
|
|
35
35
|
let waitedMs = 0;
|
|
36
|
+
let suiteEntered = false;
|
|
36
37
|
let exitHandler = null;
|
|
37
38
|
let saved = null;
|
|
38
39
|
|
|
@@ -52,6 +53,7 @@ export function createHeavyGate({
|
|
|
52
53
|
lease = null;
|
|
53
54
|
restoreEnv();
|
|
54
55
|
entered = false;
|
|
56
|
+
suiteEntered = false;
|
|
55
57
|
};
|
|
56
58
|
|
|
57
59
|
const raiseFloor = (kind) => {
|
|
@@ -65,12 +67,29 @@ export function createHeavyGate({
|
|
|
65
67
|
admittedGb = floor;
|
|
66
68
|
};
|
|
67
69
|
|
|
70
|
+
const awaitSuite = () => {
|
|
71
|
+
const result = enterVerifySuite({
|
|
72
|
+
dir: heavyDir(env),
|
|
73
|
+
lease,
|
|
74
|
+
slots: config.slots,
|
|
75
|
+
waitTimeoutMs: config.waitTimeoutSec * 1000,
|
|
76
|
+
maxHoldMs: config.maxHoldMinutes * 60_000,
|
|
77
|
+
log,
|
|
78
|
+
...deps,
|
|
79
|
+
});
|
|
80
|
+
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
81
|
+
waitedMs += result.waitedMs;
|
|
82
|
+
suiteEntered = true;
|
|
83
|
+
};
|
|
84
|
+
|
|
68
85
|
return {
|
|
69
86
|
config,
|
|
70
87
|
get waitedMs() { return waitedMs; },
|
|
71
88
|
|
|
72
89
|
enter(node, kind = heavyClass(node)) {
|
|
90
|
+
const suite = isVerifySuite({ label, node });
|
|
73
91
|
if (entered) {
|
|
92
|
+
if (suite && lease && !suiteEntered) awaitSuite();
|
|
74
93
|
raiseFloor(kind);
|
|
75
94
|
updateLease(lease, { node });
|
|
76
95
|
return;
|
|
@@ -86,6 +105,7 @@ export function createHeavyGate({
|
|
|
86
105
|
feature,
|
|
87
106
|
node,
|
|
88
107
|
kind,
|
|
108
|
+
suite,
|
|
89
109
|
cwd: projectPath,
|
|
90
110
|
minFreeMemGb,
|
|
91
111
|
waitTimeoutMs: config.waitTimeoutSec * 1000,
|
|
@@ -96,6 +116,7 @@ export function createHeavyGate({
|
|
|
96
116
|
});
|
|
97
117
|
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
98
118
|
lease = result.lease;
|
|
119
|
+
suiteEntered = suite;
|
|
99
120
|
admittedGb = minFreeMemGb;
|
|
100
121
|
waitedMs = result.waitedMs;
|
|
101
122
|
}
|
|
@@ -21,7 +21,8 @@ export const reviewFileName = (role) => OUTPUT_SCHEMA[REVIEW_ROLES[role].output]
|
|
|
21
21
|
// Append-only: uma reavaliação acrescenta ao fim, e vale o ÚLTIMO token/seção.
|
|
22
22
|
const VERDICT_RE = /\*\*Verdict:\*\*\s*([A-Za-z]+)/g;
|
|
23
23
|
const REVIEWED_SHA_RE = /\*\*Reviewed-SHA:\*\*\s*`?([0-9a-f]{7,40})/gi;
|
|
24
|
-
|
|
24
|
+
// A confirmation round nests its sections one level down (`###`); both levels count.
|
|
25
|
+
const sectionRe = (title) => new RegExp(`^(#{2,3})\\s+${title}\\s*$`, 'gim');
|
|
25
26
|
const BLOCKERS_SECTION_RE = sectionRe('Bloqueadores');
|
|
26
27
|
const CHECKS_SECTION_RE = sectionRe('Checks');
|
|
27
28
|
const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
|
|
@@ -37,7 +38,7 @@ export function requiredReviewRoles({ workType, securityRequired = false } = {})
|
|
|
37
38
|
function sectionBodies(text, re) {
|
|
38
39
|
return [...text.matchAll(re)].map((m) => {
|
|
39
40
|
const rest = text.slice(m.index + m[0].length);
|
|
40
|
-
const next = rest.search(
|
|
41
|
+
const next = rest.search(new RegExp(`^#{1,${m[1].length}}\\s`, 'm'));
|
|
41
42
|
return next === -1 ? rest : rest.slice(0, next);
|
|
42
43
|
});
|
|
43
44
|
}
|
|
@@ -50,13 +51,26 @@ function lastSectionBody(text, re) {
|
|
|
50
51
|
const normalize = (s) => String(s ?? '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
|
|
51
52
|
const cleanId = (s) => String(s ?? '').replace(/[*`_]/g, '').trim().toUpperCase();
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
const isSeparator = (cells) => cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '');
|
|
55
|
+
|
|
56
|
+
// A status table a confirmation lists under "Nenhum." is skipped; any other header off the
|
|
57
|
+
// contract still counts, so its rows land in `untyped` and pause the gate.
|
|
58
|
+
const isStatusHeader = (header) => /^(situacao|status)$/.test(normalize(header[1]));
|
|
59
|
+
|
|
60
|
+
function tableRows(body, keepHeader = () => true) {
|
|
54
61
|
const rows = [];
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
let skipping = false;
|
|
63
|
+
const lines = body.split(/\r?\n/);
|
|
64
|
+
for (const [i, line] of lines.entries()) {
|
|
65
|
+
if (!/^\s*\|/.test(line)) { skipping = false; continue; }
|
|
57
66
|
const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
|
|
58
|
-
if (cells
|
|
59
|
-
|
|
67
|
+
if (isSeparator(cells)) continue;
|
|
68
|
+
const next = lines[i + 1]?.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
|
|
69
|
+
if (next && /^\s*\|/.test(lines[i + 1]) && isSeparator(next) && !skipping) {
|
|
70
|
+
skipping = !keepHeader(cells);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!skipping) rows.push(cells);
|
|
60
74
|
}
|
|
61
75
|
return rows;
|
|
62
76
|
}
|
|
@@ -65,8 +79,7 @@ function tableRows(body) {
|
|
|
65
79
|
function parseBlockers(body) {
|
|
66
80
|
const blockers = [];
|
|
67
81
|
const untyped = [];
|
|
68
|
-
for (const cells of tableRows(body)) {
|
|
69
|
-
if (/^#$|^id$/i.test(cells[0]) && /^tipo$/i.test(cells[1] ?? '')) continue;
|
|
82
|
+
for (const cells of tableRows(body, (header) => !isStatusHeader(header))) {
|
|
70
83
|
const row = { id: cleanId(cells[0]), type: normalize(cells[1]), where: cells[2] ?? '', evidence: cells[3] ?? '' };
|
|
71
84
|
(BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
|
|
72
85
|
}
|
|
@@ -78,7 +91,7 @@ const STATUS_BY_KEY = Object.fromEntries(CHECK_STATUSES.map((s) => [normalize(s)
|
|
|
78
91
|
// Vale a última linha de cada Cn em todas as seções: uma confirmação traz só os Cn que mudou.
|
|
79
92
|
function parseChecks(bodies) {
|
|
80
93
|
const byId = new Map();
|
|
81
|
-
for (const cells of bodies.flatMap(tableRows)) {
|
|
94
|
+
for (const cells of bodies.flatMap((body) => tableRows(body))) {
|
|
82
95
|
const id = cleanId(cells[0]);
|
|
83
96
|
if (!/^C\d+$/.test(id)) continue;
|
|
84
97
|
const raw = String(cells[1] ?? '').replace(/[*`]/g, '').trim();
|
|
@@ -9,6 +9,14 @@ import { PACKAGE_LOCATIONS } from '../detectors/index.js';
|
|
|
9
9
|
/** `none` yields an UNKNOWN count, never a fabricated zero. */
|
|
10
10
|
export const TEST_RUNNERS = ['dotnet', 'vitest', 'jest', 'node-test', 'playwright', 'none', EVAL_RUNNER];
|
|
11
11
|
|
|
12
|
+
// A count is a baseline only where it stays still: dotnet/vitest/jest/playwright grow every
|
|
13
|
+
// slice, so their baseline only warned or blocked a deliberate deletion.
|
|
14
|
+
const BASELINE_RUNNERS = ['node-test', EVAL_RUNNER];
|
|
15
|
+
|
|
16
|
+
export function keepsBaseline(runner) {
|
|
17
|
+
return BASELINE_RUNNERS.includes(runner);
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export const TEST_NODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
13
21
|
|
|
14
22
|
const FALLBACK_NODE_ID = 'default';
|
|
@@ -67,12 +75,13 @@ export function inferRunner(command) {
|
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
function makeNode(partial) {
|
|
78
|
+
const runner = partial.runner ?? inferRunner(partial.command);
|
|
70
79
|
return {
|
|
71
80
|
id: partial.id,
|
|
72
81
|
command: partial.command ?? null,
|
|
73
82
|
cwd: partial.cwd ?? '.',
|
|
74
|
-
runner
|
|
75
|
-
expectedCount: partial.expectedCount ?? null,
|
|
83
|
+
runner,
|
|
84
|
+
expectedCount: keepsBaseline(runner) ? partial.expectedCount ?? null : null,
|
|
76
85
|
timeoutMs: partial.timeoutMs ?? null,
|
|
77
86
|
build: partial.build ?? [],
|
|
78
87
|
e2e: partial.e2e ?? false,
|
|
@@ -329,11 +338,11 @@ export function morphConfigPath(projectPath = '.') {
|
|
|
329
338
|
* Refuses a non-integer count (null is UNKNOWN, never 0), an undeclared id, or a
|
|
330
339
|
* declared node with no measurement. Every other config key survives.
|
|
331
340
|
* @param {Array<{id: string, count: number|null}>} measurements
|
|
332
|
-
* @returns {{ recorded: boolean, reason: string|null, updates: Array<{id: string, from: number|null, to: number}>, path: string }}
|
|
341
|
+
* @returns {{ recorded: boolean, reason: string|null, updates: Array<{id: string, from: number|null, to: number}>, removed: string[], path: string }}
|
|
333
342
|
*/
|
|
334
343
|
export function recordTestBaselines(projectPath, measurements) {
|
|
335
344
|
const path = morphConfigPath(projectPath);
|
|
336
|
-
const refuse = (reason) => ({ recorded: false, reason, updates: [], path });
|
|
345
|
+
const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [], path });
|
|
337
346
|
|
|
338
347
|
if (!existsSync(path)) return refuse('não existe .morph/config/config.json — não há onde gravar');
|
|
339
348
|
|
|
@@ -349,7 +358,10 @@ export function recordTestBaselines(projectPath, measurements) {
|
|
|
349
358
|
return refuse('config.json não declara project.tests[] — não há entrada onde gravar a baseline');
|
|
350
359
|
}
|
|
351
360
|
|
|
352
|
-
const
|
|
361
|
+
const runnerOf = new Map(declared.filter(isPlainObject).map((e) => [e.id, e.runner ?? inferRunner(e.command)]));
|
|
362
|
+
const unreadable = measurements
|
|
363
|
+
.filter((m) => keepsBaseline(runnerOf.get(m.id)) && !Number.isInteger(m.count))
|
|
364
|
+
.map((m) => m.id);
|
|
353
365
|
if (unreadable.length > 0) {
|
|
354
366
|
return refuse(`contagem desconhecida em: ${unreadable.join(', ')} — desconhecido nunca vira 0`);
|
|
355
367
|
}
|
|
@@ -370,8 +382,14 @@ export function recordTestBaselines(projectPath, measurements) {
|
|
|
370
382
|
}
|
|
371
383
|
|
|
372
384
|
const updates = [];
|
|
385
|
+
const removed = [];
|
|
373
386
|
for (const entry of declared) {
|
|
374
387
|
if (!entry || !byId.has(entry.id)) continue;
|
|
388
|
+
if (!keepsBaseline(runnerOf.get(entry.id))) {
|
|
389
|
+
if ('expectedCount' in entry) removed.push(entry.id);
|
|
390
|
+
delete entry.expectedCount;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
375
393
|
const to = byId.get(entry.id);
|
|
376
394
|
updates.push({
|
|
377
395
|
id: entry.id,
|
|
@@ -382,5 +400,5 @@ export function recordTestBaselines(projectPath, measurements) {
|
|
|
382
400
|
}
|
|
383
401
|
|
|
384
402
|
atomicWriteJson(path, config);
|
|
385
|
-
return { recorded: true, reason: null, updates, path };
|
|
403
|
+
return { recorded: true, reason: null, updates, removed, path };
|
|
386
404
|
}
|
|
@@ -8,7 +8,8 @@ import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
|
|
|
8
8
|
import { CONFIG_FILE_NAME } from '../../core/state/state-manager.js';
|
|
9
9
|
|
|
10
10
|
// Per chained project: a fixed cap timed a long `a && b` chain out for being long, not broken.
|
|
11
|
-
export const DEFAULT_TEST_TIMEOUT_PER_PROJECT =
|
|
11
|
+
export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 1_800_000;
|
|
12
|
+
const TEST_OUTPUT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Fallback test command when the project declared no nodes; `projectCount` scales the timeout.
|
|
@@ -423,6 +424,45 @@ export function parseTestCount(output, runner) {
|
|
|
423
424
|
return extractor(stripAnsi(output));
|
|
424
425
|
}
|
|
425
426
|
|
|
427
|
+
const FAILURE_LINES_MAX = 10;
|
|
428
|
+
const TAP_NOT_OK = /^(\s*)not ok \d+ - (.+?)\s*$/;
|
|
429
|
+
const TAP_DIRECTIVE = /\s#\s*(?:TODO|SKIP)\b/i;
|
|
430
|
+
const VITEST_FAIL = /^\s*FAIL\s+(.+?)\s*$/;
|
|
431
|
+
const VITEST_CROSS = /^\s*[×✗]\s+(.+?)(?:\s+\d+(?:\.\d+)?\s*m?s)?\s*$/;
|
|
432
|
+
// The duration suffix is required: it separates a test line from the summary's `failed: 1` / `Failed!`.
|
|
433
|
+
const DOTNET_FAILED = /^\s*(?:failed|com falha)\s+(\S.*?)\s*(?:\[[^\]]*\]|\([^)]*\))\s*$/i;
|
|
434
|
+
|
|
435
|
+
function tapLeafFailures(lines) {
|
|
436
|
+
const seen = [];
|
|
437
|
+
for (const line of lines) {
|
|
438
|
+
const match = TAP_NOT_OK.exec(line);
|
|
439
|
+
if (!match || TAP_DIRECTIVE.test(match[2])) continue;
|
|
440
|
+
const depth = match[1].length;
|
|
441
|
+
// TAP prints a subtest's result before its parent's: deeper unclaimed entries above are its children.
|
|
442
|
+
const children = seen.filter((e) => !e.claimed && e.depth > depth);
|
|
443
|
+
for (const child of children) child.claimed = true;
|
|
444
|
+
seen.push({ depth, name: match[2], leaf: children.length === 0, claimed: false });
|
|
445
|
+
}
|
|
446
|
+
return seen.filter((e) => e.leaf).map((e) => e.name);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function captures(lines, pattern) {
|
|
450
|
+
return lines.map((line) => pattern.exec(line)?.[1]).filter(Boolean);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Failed test names anywhere in the output (TAP leaves, vitest, dotnet), deduplicated, at most 10; [] when no form matches. */
|
|
454
|
+
export function failureLines(output) {
|
|
455
|
+
if (typeof output !== 'string' || !output) return [];
|
|
456
|
+
const lines = stripAnsi(output).split(/\r?\n/);
|
|
457
|
+
const vitestFiles = captures(lines, VITEST_FAIL);
|
|
458
|
+
const names = [
|
|
459
|
+
...tapLeafFailures(lines),
|
|
460
|
+
...(vitestFiles.length > 0 ? vitestFiles : captures(lines, VITEST_CROSS)),
|
|
461
|
+
...captures(lines, DOTNET_FAILED),
|
|
462
|
+
];
|
|
463
|
+
return [...new Set(names)].slice(0, FAILURE_LINES_MAX);
|
|
464
|
+
}
|
|
465
|
+
|
|
426
466
|
/**
|
|
427
467
|
* Asymmetric on purpose: only a drop fails (non-null `reason`); a rise only warns,
|
|
428
468
|
* or every new test would redden the gate and teach deleting tests.
|
|
@@ -496,6 +536,8 @@ export function runTestSuite(projectPath = '.', opts = {}) {
|
|
|
496
536
|
timeout,
|
|
497
537
|
encoding: 'utf8',
|
|
498
538
|
stdio: 'pipe',
|
|
539
|
+
// Node's 1 MiB default kills a large suite mid-run (ENOBUFS), which then reads as a red suite.
|
|
540
|
+
maxBuffer: TEST_OUTPUT_MAX_BUFFER,
|
|
499
541
|
...(env ? { env } : {}),
|
|
500
542
|
});
|
|
501
543
|
return { passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout };
|
|
@@ -5,6 +5,7 @@ import { ingestedBytes } from './telemetry-reader.js';
|
|
|
5
5
|
import { producerOf } from '../../../framework/hooks/shared/command-tokens.js';
|
|
6
6
|
import { isFailedToolCall } from '../../../framework/hooks/shared/telemetry-logger.js';
|
|
7
7
|
import { GATE_EVENT, GATE_STEPS } from '../../../framework/hooks/shared/gate-event.js';
|
|
8
|
+
import { QUEUE_FULL_EXIT, REVIEW_AGENT_TYPES, morphVerifyCall, testRunKind } from '../../../framework/hooks/shared/heavy-command.js';
|
|
8
9
|
|
|
9
10
|
export const RERUN_WINDOW_MS = 10 * 60 * 1000;
|
|
10
11
|
|
|
@@ -81,13 +82,53 @@ function newCounters() {
|
|
|
81
82
|
};
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
const newTimeSlot = () => ({ runs: 0, measuredMs: 0, unmeasured: 0 });
|
|
86
|
+
|
|
87
|
+
function addRun(slot, durationMs) {
|
|
86
88
|
slot.runs += 1;
|
|
87
89
|
if (Number.isFinite(durationMs)) slot.measuredMs += durationMs;
|
|
88
90
|
else slot.unmeasured += 1;
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
// `agent_type` carries the dispatch name when one is given (`ver-c`, `gate3-motor`), not the persona,
|
|
94
|
+
// so the metric also reads Gate 3 names; `byPersona` is the part the test-budget hook recognizes.
|
|
95
|
+
const REVIEW_NAME = /^(?:ver|rev|sec|g3|gate3|eval|review|reviewer|security)\d*-/;
|
|
96
|
+
const isReviewAgent = (type) => REVIEW_AGENT_TYPES.has(type) || type === 'morph-evaluator' || REVIEW_NAME.test(String(type ?? ''));
|
|
97
|
+
|
|
98
|
+
// Same split as the test-budget hook: review first, then an unfiltered verify, then the round count.
|
|
99
|
+
// A run refused by the full heavy queue ran no test.
|
|
100
|
+
function testOutsideVerify(bashEvents) {
|
|
101
|
+
const out = {
|
|
102
|
+
total: newTimeSlot(),
|
|
103
|
+
subagentVerify: newTimeSlot(),
|
|
104
|
+
implementerRound3Plus: newTimeSlot(),
|
|
105
|
+
reviewTests: { ...newTimeSlot(), byPersona: newTimeSlot() },
|
|
106
|
+
};
|
|
107
|
+
const byAgent = new Map();
|
|
108
|
+
for (const e of bashEvents) {
|
|
109
|
+
if (e.event === 'PostToolUseFailure' && e.exitCode === QUEUE_FULL_EXIT) continue;
|
|
110
|
+
const verify = morphVerifyCall(e.command);
|
|
111
|
+
if (!verify && !testRunKind(e.command)) continue;
|
|
112
|
+
addRun(out.total, e.durationMs);
|
|
113
|
+
if (!e.agentId) continue;
|
|
114
|
+
if (isReviewAgent(e.agentType)) {
|
|
115
|
+
addRun(out.reviewTests, e.durationMs);
|
|
116
|
+
if (REVIEW_AGENT_TYPES.has(e.agentType)) addRun(out.reviewTests.byPersona, e.durationMs);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!byAgent.has(e.agentId)) byAgent.set(e.agentId, []);
|
|
120
|
+
byAgent.get(e.agentId).push({ e, unfilteredVerify: Boolean(verify && !verify.filtered) });
|
|
121
|
+
}
|
|
122
|
+
for (const runs of byAgent.values()) {
|
|
123
|
+
runs.sort((a, b) => String(a.e.ts ?? '').localeCompare(String(b.e.ts ?? '')));
|
|
124
|
+
runs.forEach(({ e, unfilteredVerify }, round) => {
|
|
125
|
+
if (unfilteredVerify) addRun(out.subagentVerify, e.durationMs);
|
|
126
|
+
else if (round >= 2) addRun(out.implementerRound3Plus, e.durationMs);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
91
132
|
function finishCounters(c, turnsObserved) {
|
|
92
133
|
return {
|
|
93
134
|
toolCalls: c.toolCalls,
|
|
@@ -180,6 +221,7 @@ export function computeMetrics(events, opts = {}) {
|
|
|
180
221
|
const reuse = { runs: 0, byTree: {}, skipReasons: {} };
|
|
181
222
|
const gates = {};
|
|
182
223
|
const openPauses = new Map();
|
|
224
|
+
const testBash = [];
|
|
183
225
|
let v2 = 0;
|
|
184
226
|
let legacy = 0;
|
|
185
227
|
let turnsObserved = false;
|
|
@@ -257,10 +299,11 @@ export function computeMetrics(events, opts = {}) {
|
|
|
257
299
|
}
|
|
258
300
|
|
|
259
301
|
if (e.tool !== 'Bash' && e.tool !== 'PowerShell') continue;
|
|
302
|
+
if (e.tool === 'Bash' && typeof e.command === 'string') testBash.push(e);
|
|
260
303
|
const { kind, klass } = classifyCommand(e.command);
|
|
261
304
|
const git = gitCalls(e.command);
|
|
262
305
|
for (const c of sinks) {
|
|
263
|
-
|
|
306
|
+
addRun(c.timeByClass[klass] ||= newTimeSlot(), e.durationMs);
|
|
264
307
|
c.git.status += git.status;
|
|
265
308
|
c.git.diff += git.diff;
|
|
266
309
|
}
|
|
@@ -335,6 +378,7 @@ export function computeMetrics(events, opts = {}) {
|
|
|
335
378
|
unchangedRepeats,
|
|
336
379
|
},
|
|
337
380
|
dotnetFullSuite: fullSuite,
|
|
381
|
+
testOutsideVerify: testOutsideVerify(testBash),
|
|
338
382
|
reuse,
|
|
339
383
|
gates,
|
|
340
384
|
features: featureRows,
|
|
@@ -77,7 +77,7 @@ export class PackageValidator {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
return {
|
|
80
|
-
status:
|
|
80
|
+
status: allIssues.some(i => i.level === 'error') ? 'error' : 'ok',
|
|
81
81
|
totalFiles: csprojFiles.length,
|
|
82
82
|
filesWithIssues: results.length,
|
|
83
83
|
// The runner's contract: without these counts the findings read as `errors: 0`.
|
|
@@ -301,7 +301,7 @@ export async function validatePackages(projectPath = '.', options = {}) {
|
|
|
301
301
|
validator.formatResults(results);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
if (options.autoFix && results.
|
|
304
|
+
if (options.autoFix && results.results?.length) {
|
|
305
305
|
const fixedCount = await validator.autoFix(results);
|
|
306
306
|
console.log(chalk.green(`\n✅ Auto-fixed ${fixedCount} issue(s)`));
|
|
307
307
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
|
-
import { existsSync, readFileSync, rmSync } from 'fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
3
|
import { join, resolve } from 'path';
|
|
4
4
|
|
|
5
5
|
import { runBuildCheck, getBuildSteps, isDotnetStack } from '../../../framework/hooks/shared/build-check.js';
|
|
@@ -9,7 +9,7 @@ import { composeProjectName, runE2eCheck } from '../../../framework/hooks/shared
|
|
|
9
9
|
import { buildComposeEnv } from '../e2e/compose-env.js';
|
|
10
10
|
import { e2eApplicability } from '../e2e/applicability.js';
|
|
11
11
|
import {
|
|
12
|
-
runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution,
|
|
12
|
+
runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution, failureLines,
|
|
13
13
|
} from '../tasks/test-runner.js';
|
|
14
14
|
import {
|
|
15
15
|
resolveTestNodes, parseTestFilter, nodeMatchesFilter, recordTestBaselines, morphConfigPath,
|
|
@@ -24,8 +24,7 @@ import { runValidation } from './validation-runner.js';
|
|
|
24
24
|
import { checkOutputsTraceability } from './traceability-check.js';
|
|
25
25
|
import { createHeavyGate } from '../heavy/gate.js';
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
const DEFAULT_BUILD_TIMEOUT = 120_000;
|
|
27
|
+
const DEFAULT_BUILD_TIMEOUT = 1_800_000;
|
|
29
28
|
|
|
30
29
|
const VALIDATOR_FEEDBACK_LIMIT = 10;
|
|
31
30
|
|
|
@@ -364,7 +363,8 @@ async function runNodesInLinux({
|
|
|
364
363
|
// No exit code is NOT a pass: the container died before this node reported.
|
|
365
364
|
? { passed: false, output: raw.output || '', reason: raw.reason, exitCode: null }
|
|
366
365
|
: { passed: raw.exitCode === 0, output: raw.output || '', exitCode: raw.exitCode };
|
|
367
|
-
|
|
366
|
+
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
367
|
+
return judgeRun({ node, result, durationMs: raw.durationMs ?? 0, logPath });
|
|
368
368
|
});
|
|
369
369
|
|
|
370
370
|
return { runs, environment: suite.environment };
|
|
@@ -389,7 +389,9 @@ async function runOneTestNode({
|
|
|
389
389
|
const result = await _runTestSuite(projectPath, {
|
|
390
390
|
timeout, node, ...(childEnv ? { env: childEnv } : {}),
|
|
391
391
|
});
|
|
392
|
-
const
|
|
392
|
+
const durationMs = Date.now() - start;
|
|
393
|
+
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
394
|
+
const entry = judgeRun({ node, result, durationMs, logPath });
|
|
393
395
|
if (!evalChannel) return entry;
|
|
394
396
|
|
|
395
397
|
const judged = judgeEvalRun(entry, node, evalChannel.summaryPath);
|
|
@@ -397,6 +399,19 @@ async function runOneTestNode({
|
|
|
397
399
|
return judged;
|
|
398
400
|
}
|
|
399
401
|
|
|
402
|
+
// Fail-open: a log that cannot be written must not change the verdict.
|
|
403
|
+
function writeFailedRunLog(projectPath, node, result) {
|
|
404
|
+
if (result.passed || result.skipped || !result.output) return null;
|
|
405
|
+
const relPath = `.morph/logs/verify-tests-${String(node.id).replace(/[^\w.-]/g, '_')}.log`;
|
|
406
|
+
try {
|
|
407
|
+
mkdirSync(join(projectPath, '.morph', 'logs'), { recursive: true });
|
|
408
|
+
writeFileSync(join(projectPath, relPath), result.output);
|
|
409
|
+
return relPath;
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
400
415
|
function discardFile(path) {
|
|
401
416
|
try { rmSync(path, { force: true }); } catch { /* the next run removes it first */ }
|
|
402
417
|
}
|
|
@@ -476,13 +491,15 @@ function preDecideRun({ node, filter, projectPath = '.', refreshCache = false })
|
|
|
476
491
|
}
|
|
477
492
|
|
|
478
493
|
// The single count rule for host and container runs; a second copy makes them disagree.
|
|
479
|
-
function judgeRun({ node, result, durationMs }) {
|
|
494
|
+
function judgeRun({ node, result, durationMs, logPath = null }) {
|
|
480
495
|
const entry = emptyRunEntry(node);
|
|
481
496
|
|
|
482
497
|
if (result.skipped) {
|
|
483
498
|
return { ...entry, durationMs, reason: result.reason || 'no test command detected' };
|
|
484
499
|
}
|
|
485
500
|
|
|
501
|
+
const logLine = logPath ? [`log completo: ${logPath}`] : [];
|
|
502
|
+
|
|
486
503
|
// A truncated log tail reads like a passing run cut mid-sentence: lead with the timeout.
|
|
487
504
|
if (result.timedOut) {
|
|
488
505
|
const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
|
|
@@ -492,7 +509,7 @@ function judgeRun({ node, result, durationMs }) {
|
|
|
492
509
|
durationMs,
|
|
493
510
|
timedOut: true,
|
|
494
511
|
reason: result.message,
|
|
495
|
-
output:
|
|
512
|
+
output: [result.message, ...logLine, tail].filter(Boolean).join('\n'),
|
|
496
513
|
};
|
|
497
514
|
}
|
|
498
515
|
|
|
@@ -511,18 +528,39 @@ function judgeRun({ node, result, durationMs }) {
|
|
|
511
528
|
}
|
|
512
529
|
if (verdict.verdict === 'drop') warnings.push(verdict.reason);
|
|
513
530
|
|
|
531
|
+
// Without a baseline, zero is the floor: a green run over no test is a suite that stopped running.
|
|
532
|
+
if (result.passed && count === 0 && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
|
|
533
|
+
return { ...entry, status: 'fail', durationMs, count, warnings, reason: 'suíte verde sem nenhum teste executado', output: '' };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (result.passed) {
|
|
537
|
+
return { ...entry, status: 'pass', durationMs, count, warnings, reason: result.reason ?? null, output: '' };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// The failing test sits thousands of lines above a TAP summary, never in the tail.
|
|
541
|
+
const failed = failureLines(result.output);
|
|
514
542
|
return {
|
|
515
543
|
...entry,
|
|
516
|
-
status:
|
|
544
|
+
status: 'fail',
|
|
517
545
|
durationMs,
|
|
518
546
|
count,
|
|
519
547
|
warnings,
|
|
520
548
|
// `test-build-failed` / `stale-binary`: refused before any test ran.
|
|
521
|
-
reason: result.reason ??
|
|
522
|
-
output:
|
|
549
|
+
reason: result.reason ?? failureSummary(failed),
|
|
550
|
+
output: [
|
|
551
|
+
...failed.map((name) => `falhou: ${name}`),
|
|
552
|
+
...logLine,
|
|
553
|
+
tailLines(result.output, OUTPUT_TAIL_LINES),
|
|
554
|
+
].filter(Boolean).join('\n'),
|
|
523
555
|
};
|
|
524
556
|
}
|
|
525
557
|
|
|
558
|
+
function failureSummary(failed) {
|
|
559
|
+
if (failed.length === 0) return null;
|
|
560
|
+
const more = failed.length > 3 ? ` (+${failed.length - 3})` : '';
|
|
561
|
+
return `falhou: ${failed.slice(0, 3).join('; ')}${more}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
526
564
|
function acceptRecordedDrops(testsNode) {
|
|
527
565
|
const runs = testsNode.runs.map(({ countDrop, ...run }) => (countDrop ? { ...run, status: 'pass', reason: null } : run));
|
|
528
566
|
return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
|
|
@@ -615,7 +653,7 @@ function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
|
|
|
615
653
|
// exited 0 is the one red it accepts: lowering the number is how deleting tests passes.
|
|
616
654
|
function recordBaselineFrom(projectPath, testsNode) {
|
|
617
655
|
const runs = testsNode.runs || [];
|
|
618
|
-
const refuse = (reason) => ({ recorded: false, reason, updates: [] });
|
|
656
|
+
const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [] });
|
|
619
657
|
|
|
620
658
|
if (runs.length === 0) return refuse('nenhum nó de teste executou');
|
|
621
659
|
|
|
@@ -284,8 +284,11 @@ export const DISPATCH_CONTRACT = [
|
|
|
284
284
|
'ANTES de encerrar — é o retorno autoritativo, não a sua mensagem final.',
|
|
285
285
|
'2. ESTADO: NÃO toque em `plan.md`, `checks.json`, `feature.json` nem `state.json`: o estado é do orquestrador.',
|
|
286
286
|
'3. TESTE: a prova é do orquestrador (`morph-spec verify {feature}`, depois do seu retorno), não sua. Escreva os testes pelo orçamento: ' +
|
|
287
|
-
'um por Cn `prova: teste`, variantes em tabela (`[Theory]`/`it.each`); `leitura` sem teste. NÃO rode teste a cada marco nem faça ciclo red→green: ' +
|
|
288
|
-
'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório
|
|
287
|
+
'um por Cn `prova: teste`, na camada mais baixa que o prova (a de cima só para o que só existe lá), variantes em tabela (`[Theory]`/`it.each`); `leitura` sem teste. NÃO rode teste a cada marco nem faça ciclo red→green: ' +
|
|
288
|
+
'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório ' +
|
|
289
|
+
'(`vitest related <arquivos da fatia> --run`; `dotnet test --filter "FullyQualifiedName~<Classe>" --no-build` após o build); ' +
|
|
290
|
+
'se falhar, corrija e rode só os que falharam. Teto: 3 rodadas (`verify --filter` conta, exit 3 não); ' +
|
|
291
|
+
'o hook nega a 4ª e o `verify` sem `--filter`: grave `PARCIAL` com o que ainda falha e devolva. ' +
|
|
289
292
|
'Grave a saída em arquivo (`… > .morph/logs/test-last.log 2>&1`, sem nada depois) e leia o arquivo — um pipe troca o exit do teste pelo do último comando. ' +
|
|
290
293
|
'Em batch paralelo SEM isolamento, só compile. Com `isolation: "worktree"`, o mesmo vale no seu worktree, e o último ato é o commit na branch dele.',
|
|
291
294
|
'4. SOBREVIVÊNCIA: o limite de turnos corta sem aviso: commite `chore(F{n}): wip …` a cada marco (um arquivo ou uma fatia escrita), não só ao fim — ' +
|
package/src/utils/file-copier.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
2
3
|
import { join, dirname, resolve } from 'path';
|
|
3
4
|
import { fileURLToPath } from 'url';
|
|
4
5
|
import { WORKTREE_DIR_NAMES } from '../lib/worktree/dirs.js';
|
|
@@ -140,18 +141,39 @@ const ignoredDir = (line) => line.trim()
|
|
|
140
141
|
.replace(/^\[(\w)(\w)\]/, (_, a, b) => (a.toLowerCase() === b.toLowerCase() ? a : `[${a}${b}]`))
|
|
141
142
|
.toLowerCase();
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
function trackedUnder(projectPath, dirs) {
|
|
145
|
+
let files;
|
|
146
|
+
try {
|
|
147
|
+
files = execFileSync('git', ['ls-files', '-z'], { cwd: projectPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\0');
|
|
148
|
+
} catch { return new Map(); }
|
|
149
|
+
const found = new Map();
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
const segments = file.toLowerCase().split('/').slice(0, -1);
|
|
152
|
+
for (const dir of dirs) if (!found.has(dir) && segments.includes(dir)) found.set(dir, file);
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Never rewrites an existing line. Build output is never versioned, so a tracked file
|
|
159
|
+
* under a `bin`/`obj` segment is source that entry would hide: the entry is skipped.
|
|
160
|
+
* @returns {string[]} the entries added; `.skipped` lists `{ entry, file }` when any was skipped
|
|
161
|
+
*/
|
|
144
162
|
export function ensureDotnetBuildIgnores(projectPath) {
|
|
145
163
|
const gitignorePath = join(projectPath, '.gitignore');
|
|
146
164
|
const content = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
|
|
147
165
|
const present = new Set(content.split(/\r?\n/).filter((l) => !l.trim().startsWith('#')).map(ignoredDir));
|
|
148
|
-
const
|
|
149
|
-
|
|
166
|
+
const absent = DOTNET_BUILD_DIRS.filter((dir) => !present.has(dir));
|
|
167
|
+
const tracked = absent.length ? trackedUnder(projectPath, absent) : new Map();
|
|
168
|
+
const missing = absent.filter((dir) => !tracked.has(dir)).map((dir) => `${dir}/`);
|
|
169
|
+
const skipped = [...tracked].map(([dir, file]) => ({ entry: `${dir}/`, file }));
|
|
170
|
+
const result = (added) => (skipped.length ? Object.assign(added, { skipped }) : added);
|
|
171
|
+
if (!missing.length) return result([]);
|
|
150
172
|
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
151
173
|
const lead = content && !content.endsWith('\n') ? eol : '';
|
|
152
174
|
const block = [...(content ? [''] : []), '# .NET build output', ...missing, ''].join(eol);
|
|
153
175
|
fs.writeFileSync(gitignorePath, content + lead + block);
|
|
154
|
-
return missing;
|
|
176
|
+
return result(missing);
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
// Without the LF pin, Windows autocrlf marks every rewritten feature.json dirty
|