@polymorphism-tech/morph-spec 9.4.0 → 9.6.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 +1 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +23 -33
- package/framework/agents.json +6 -7
- package/framework/docs/CLI.md +12 -8
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/comment-guard.js +1 -0
- package/framework/hooks/claude-code/core/denylist.json +63 -1
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +0 -8
- package/framework/hooks/claude-code/core/gate-guard.js +0 -8
- package/framework/hooks/claude-code/core/heavy-gate.js +5 -3
- package/framework/hooks/claude-code/core/loop-detect.js +0 -3
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +2 -1
- package/framework/hooks/claude-code/core/protect-spec-files.js +0 -7
- package/framework/hooks/claude-code/core/validate-completion.js +0 -4
- package/framework/hooks/claude-code/core/worktree-context.js +0 -2
- package/framework/hooks/claude-code/statusline.py +3 -44
- package/framework/hooks/shared/build-check.js +12 -19
- package/framework/hooks/shared/comment-rules.js +11 -1
- package/framework/hooks/shared/feature-locator.js +7 -5
- package/framework/hooks/shared/heavy-command.js +12 -0
- package/framework/hooks/shared/heavy-config.js +28 -1
- package/framework/hooks/shared/heavy-slots.js +41 -5
- package/framework/hooks/shared/hook-response.js +0 -4
- package/framework/hooks/shared/payload-utils.js +1 -35
- package/framework/hooks/shared/phase-utils.js +0 -12
- package/framework/hooks/shared/slice-progress.js +0 -2
- package/framework/hooks/shared/state-reader.js +0 -19
- package/framework/hooks/shared/telemetry-logger.js +1 -1
- package/framework/rules/frontend-standards.md +1 -2
- package/framework/rules/nextjs-standards.md +9 -8
- package/framework/schemas/config.schema.json +12 -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 +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-machine/SKILL.md +2 -2
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +2 -2
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- 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 +32 -29
- package/framework/standards/STANDARDS.meta.json +13 -15
- package/framework/standards/architecture/code-comments.md +1 -1
- package/framework/standards/frontend/admin-app/data-table.md +1 -3
- package/framework/standards/frontend/admin-app/page-structure.md +8 -4
- package/framework/standards/frontend/nextjs/app-router.md +3 -4
- package/framework/standards/frontend/nextjs/components.md +2 -15
- package/framework/standards/frontend/nextjs/data-fetching.md +2 -3
- package/framework/standards/frontend/nextjs/lint.md +141 -0
- package/framework/standards/frontend/nextjs/motion-patterns.md +2 -7
- package/framework/standards/frontend/nextjs/naming-conventions.md +1 -2
- package/framework/standards/frontend/nextjs/nextjs-patterns.md +5 -7
- package/framework/standards/frontend/nextjs/project-structure.md +3 -2
- package/framework/standards/frontend/nextjs/state-management.md +6 -7
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +1 -0
- package/framework/templates/README.md +4 -4
- package/framework/templates/REGISTRY.json +21 -309
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +54 -56
- package/package.json +3 -2
- package/src/commands/e2e/e2e.js +3 -0
- package/src/commands/project/env.js +3 -3
- package/src/commands/project/heavy.js +10 -3
- package/src/commands/project/worktree.js +2 -1
- package/src/commands/state/finish.js +17 -3
- package/src/commands/validation/mutate.js +2 -2
- package/src/commands/validation/verify.js +3 -0
- package/src/core/paths/output-schema.js +0 -9
- package/src/core/state/feature-identity.js +0 -2
- package/src/core/state/state-manager.js +1 -169
- package/src/core/templates/template-renderer.js +0 -23
- package/src/dashboard/app/lib/format.js +0 -7
- package/src/dashboard/app/lib/phases.js +0 -7
- package/src/dashboard/scanner/activity-scanner.js +0 -4
- package/src/lib/ai/ai-project-scan.js +1 -6
- package/src/lib/e2e/compose-env.js +26 -3
- package/src/lib/graph/graphify-runtime.js +0 -1
- package/src/lib/heavy/gate.js +23 -4
- package/src/lib/heavy/live-stacks.js +64 -0
- package/src/lib/mutation/snapshot.js +1 -5
- package/src/lib/risk-detector.js +9 -6
- package/src/lib/stack/stack-profile.js +0 -4
- package/src/lib/stack/stack-scan.js +15 -4
- package/src/lib/tasks/eval-node.js +1 -7
- package/src/lib/validators/validation-runner.js +0 -43
- package/src/lib/validators/verify-runner.js +18 -2
- package/src/lib/worktree/docker-audit.js +7 -3
- package/src/lib/worktree/env-allocator.js +27 -5
- package/src/lib/worktree/ignored-triage.js +8 -0
- package/src/utils/agents-installer.js +12 -19
- package/src/utils/claude-settings-manager.js +0 -28
- package/src/utils/file-copier.js +0 -53
- package/framework/hooks/shared/activity-logger.js +0 -131
- package/framework/standards/frontend/design-system/naming.md +0 -68
- package/src/commands/validation/index.js +0 -2
- package/src/lib/validators/code-budget/index.js +0 -2
- package/src/lib/validators/contracts/index.js +0 -1
- package/src/lib/validators/nextjs/index.js +0 -1
- package/src/lib/validators/packages/index.js +0 -1
- package/src/lib/validators/reuse/index.js +0 -2
|
@@ -11,7 +11,9 @@ import { allocateEnvBlock, portLayout, resolveFleetConfig, resolvePortAliases }
|
|
|
11
11
|
// Ports come from `portLayout()`, the same function that renders `.morph/worktree.env`. The credentials
|
|
12
12
|
// file loads after the layout but cannot outrank it: a `.env.e2e` copied from a sibling worktree carries
|
|
13
13
|
// that worktree's block. Ignored keys are announced on stderr; stdout carries `morph-spec env` output.
|
|
14
|
-
|
|
14
|
+
// `withCredentials: false` keeps envFile values off stdout (`morph-spec env` lands in transcripts);
|
|
15
|
+
// the withheld names go to stderr.
|
|
16
|
+
export function buildComposeEnv({ cwd, config, featureName, gitCommonDir, withCredentials = true }) {
|
|
15
17
|
const env = {};
|
|
16
18
|
let layoutKeys = null;
|
|
17
19
|
|
|
@@ -38,16 +40,37 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
|
|
|
38
40
|
const envFile = config?.e2e?.auth?.envFile;
|
|
39
41
|
if (envFile) {
|
|
40
42
|
const ignored = [];
|
|
43
|
+
const withheld = [];
|
|
44
|
+
const foreignPorts = [];
|
|
45
|
+
const layoutPorts = layoutKeys ? new Set([...layoutKeys].map((key) => env[key])) : null;
|
|
41
46
|
for (const [key, value] of Object.entries(loadEnvFile(join(cwd, envFile)))) {
|
|
42
|
-
if (layoutKeys?.has(key))
|
|
43
|
-
|
|
47
|
+
if (layoutKeys?.has(key)) {
|
|
48
|
+
ignored.push(key);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (layoutPorts && /(^|_)PORT$/.test(key) && !layoutPorts.has(value)) foreignPorts.push(`${key}=${value}`);
|
|
52
|
+
if (withCredentials) env[key] = value;
|
|
53
|
+
else withheld.push(key);
|
|
44
54
|
}
|
|
45
55
|
if (ignored.length) reportIgnoredLayoutKeys(envFile, ignored, env);
|
|
56
|
+
if (foreignPorts.length) reportForeignPorts(envFile, foreignPorts);
|
|
57
|
+
if (withheld.length) {
|
|
58
|
+
console.warn(chalk.yellow(
|
|
59
|
+
` ⚠ ${envFile}: ${withheld.join(', ')} omitida(s) — credenciais não vão para o stdout; use --with-secrets dentro de eval "$(…)".`,
|
|
60
|
+
));
|
|
61
|
+
}
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
return env;
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
function reportForeignPorts(envFile, foreignPorts) {
|
|
68
|
+
console.warn(chalk.yellow(
|
|
69
|
+
` ⚠ ${envFile}: ${foreignPorts.join(', ')} não é porta do bloco desta worktree. `
|
|
70
|
+
+ 'Se nomeia uma das portas do layout, declare-a em e2e.portEnvNames (ex.: { "postgres": "PG_PORT" }) e o bloco passa a prevalecer.',
|
|
71
|
+
));
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
function reportIgnoredLayoutKeys(envFile, ignored, env) {
|
|
52
75
|
const shown = ignored.map((key) => `${key}=${env[key]}`).join(', ');
|
|
53
76
|
console.warn(chalk.yellow(
|
|
@@ -7,7 +7,6 @@ import { execFileSync } from 'node:child_process';
|
|
|
7
7
|
export const GRAPHIFY_VERSION = '0.9.50';
|
|
8
8
|
export const GRAPHIFY_PACKAGE = 'graphifyy';
|
|
9
9
|
export const GRAPHIFY_BIN = 'graphify';
|
|
10
|
-
export const GRAPHIFY_MCP_BIN = 'graphify-mcp';
|
|
11
10
|
|
|
12
11
|
const PREFERENCE = ['uv', 'pipx', 'python3', 'python'];
|
|
13
12
|
|
package/src/lib/heavy/gate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
1
|
+
import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
2
|
+
import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
|
|
2
3
|
import {
|
|
3
|
-
acquireHeavy, describeBusy, heavyDir, releaseHeavy, updateLease,
|
|
4
|
+
acquireHeavy, awaitFreeMemory, describeBusy, heavyDir, releaseHeavy, updateLease,
|
|
4
5
|
} from '../../../framework/hooks/shared/heavy-slots.js';
|
|
5
6
|
|
|
6
7
|
export class HeavyBusyError extends Error {
|
|
@@ -11,6 +12,8 @@ export class HeavyBusyError extends Error {
|
|
|
11
12
|
this.reason = result.reason;
|
|
12
13
|
this.holders = result.holders;
|
|
13
14
|
this.freeGb = result.freeGb;
|
|
15
|
+
this.minFreeMemGb = result.minFreeMemGb ?? null;
|
|
16
|
+
this.kind = result.kind ?? null;
|
|
14
17
|
this.waitedMs = result.waitedMs;
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -28,6 +31,7 @@ export function createHeavyGate({
|
|
|
28
31
|
} = {}) {
|
|
29
32
|
let entered = false;
|
|
30
33
|
let lease = null;
|
|
34
|
+
let admittedGb = 0;
|
|
31
35
|
let waitedMs = 0;
|
|
32
36
|
let exitHandler = null;
|
|
33
37
|
let saved = null;
|
|
@@ -50,26 +54,40 @@ export function createHeavyGate({
|
|
|
50
54
|
entered = false;
|
|
51
55
|
};
|
|
52
56
|
|
|
57
|
+
const raiseFloor = (kind) => {
|
|
58
|
+
const floor = minFreeMemFor(config, kind);
|
|
59
|
+
if (!lease || floor <= admittedGb) return;
|
|
60
|
+
const result = awaitFreeMemory({
|
|
61
|
+
minFreeMemGb: floor, waitTimeoutMs: config.waitTimeoutSec * 1000, kind, log, ...deps,
|
|
62
|
+
});
|
|
63
|
+
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
64
|
+
waitedMs += result.waitedMs;
|
|
65
|
+
admittedGb = floor;
|
|
66
|
+
};
|
|
67
|
+
|
|
53
68
|
return {
|
|
54
69
|
config,
|
|
55
70
|
get waitedMs() { return waitedMs; },
|
|
56
71
|
|
|
57
|
-
enter(node) {
|
|
72
|
+
enter(node, kind = heavyClass(node)) {
|
|
58
73
|
if (entered) {
|
|
74
|
+
raiseFloor(kind);
|
|
59
75
|
updateLease(lease, { node });
|
|
60
76
|
return;
|
|
61
77
|
}
|
|
62
78
|
for (const warning of config.warnings ?? []) log(`heavy: ${warning}`);
|
|
63
79
|
|
|
64
80
|
if (config.enabled) {
|
|
81
|
+
const minFreeMemGb = minFreeMemFor(config, kind);
|
|
65
82
|
const result = acquireHeavy({
|
|
66
83
|
dir: heavyDir(env),
|
|
67
84
|
slots: config.slots,
|
|
68
85
|
label,
|
|
69
86
|
feature,
|
|
70
87
|
node,
|
|
88
|
+
kind,
|
|
71
89
|
cwd: projectPath,
|
|
72
|
-
minFreeMemGb
|
|
90
|
+
minFreeMemGb,
|
|
73
91
|
waitTimeoutMs: config.waitTimeoutSec * 1000,
|
|
74
92
|
maxHoldMs: config.maxHoldMinutes * 60_000,
|
|
75
93
|
env,
|
|
@@ -78,6 +96,7 @@ export function createHeavyGate({
|
|
|
78
96
|
});
|
|
79
97
|
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
80
98
|
lease = result.lease;
|
|
99
|
+
admittedGb = minFreeMemGb;
|
|
81
100
|
waitedMs = result.waitedMs;
|
|
82
101
|
}
|
|
83
102
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { composeProjectName, defaultExec } from '../../../framework/hooks/shared/e2e-check.js';
|
|
4
|
+
import {
|
|
5
|
+
MORPH_PROJECT_PREFIX, parseDockerJson, resolveGitCommonDir, stackBelongsTo,
|
|
6
|
+
} from '../worktree/docker-audit.js';
|
|
7
|
+
|
|
8
|
+
// Runs right after a full queue wait: a hung daemon must not add another one.
|
|
9
|
+
const LS_TIMEOUT_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
// Stacks of every worktree live under the main checkout, and so does `.morph/archive`.
|
|
12
|
+
export function repoMainRoot(cwd) {
|
|
13
|
+
const common = resolveGitCommonDir(cwd);
|
|
14
|
+
return common && basename(common) === '.git' ? dirname(common) : cwd;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dirNames(dir) {
|
|
18
|
+
try {
|
|
19
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function liveE2eStacks(cwd, { exec = defaultExec, root = repoMainRoot(cwd) } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const ls = exec('docker', ['compose', 'ls', '--format', 'json'], { cwd: root, timeout: LS_TIMEOUT_MS });
|
|
28
|
+
if (ls.code !== 0) return [];
|
|
29
|
+
const projects = parseDockerJson(ls.output);
|
|
30
|
+
if (!projects) return [];
|
|
31
|
+
|
|
32
|
+
const active = dirNames(join(root, '.morph', 'features'));
|
|
33
|
+
const owners = new Map(active.map((f) => [composeProjectName(f), { feature: f, state: 'active' }]));
|
|
34
|
+
for (const f of dirNames(join(root, '.morph', 'archive'))) {
|
|
35
|
+
const project = composeProjectName(f);
|
|
36
|
+
if (!owners.has(project)) owners.set(project, { feature: f, state: 'archived' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return projects
|
|
40
|
+
.filter((p) => String(p?.Name ?? '').startsWith(MORPH_PROJECT_PREFIX) && stackBelongsTo(p, root))
|
|
41
|
+
.map((p) => {
|
|
42
|
+
const project = String(p.Name);
|
|
43
|
+
return { project, status: p.Status ?? null, ...(owners.get(project) ?? { feature: null, state: 'unknown' }) };
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stackLine(s) {
|
|
51
|
+
if (s.state === 'archived') return ` • ${s.project}: feature ${s.feature} já arquivada — \`morph-spec e2e down ${s.feature}\` devolve a RAM`;
|
|
52
|
+
if (s.state === 'unknown') return ` • ${s.project}: nenhuma feature deste repo responde por ela — \`docker compose -p ${s.project} down\``;
|
|
53
|
+
return ` • ${s.project}: feature ${s.feature} em andamento`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function describeLiveStacks(stacks) {
|
|
57
|
+
if (!stacks.length) return '';
|
|
58
|
+
return `stacks e2e de pé neste repo (seguram RAM):\n${stacks.map(stackLine).join('\n')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function memoryBusyHint(err, cwd, deps = {}) {
|
|
62
|
+
if (err?.reason !== 'memory') return '';
|
|
63
|
+
return describeLiveStacks(liveE2eStacks(cwd, deps));
|
|
64
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
|
|
5
5
|
export const md5Of = (bytes) => createHash('md5').update(bytes).digest('hex');
|
|
@@ -63,7 +63,3 @@ export function restoreSnapshot(snapshot, { rewrite = true } = {}) {
|
|
|
63
63
|
writeJournal(snapshot);
|
|
64
64
|
return report;
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
export function discardSnapshot(snapshot) {
|
|
68
|
-
rmSync(snapshot.dir, { recursive: true, force: true });
|
|
69
|
-
}
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { MAX_SLICES } from './plan/checks.js';
|
|
2
2
|
import { BLOCKER_TYPES, reviewFileName } from './review-reports.js';
|
|
3
|
+
import { isTestPath } from './graph/sanitize.js';
|
|
4
|
+
import { isTestArtifact } from './worktree/ignored-triage.js';
|
|
3
5
|
|
|
4
6
|
const SECURITY_PATH_RE = /(^|[/._-])(auth|login|senha|password|token|secret|crypto|permiss|rbac|acl|lgpd|pii|privacy|payment|pagamento|billing|cobranca|invoice|asaas|stripe|migrations?)([/._-]|$)/i;
|
|
5
7
|
|
|
@@ -135,8 +137,14 @@ export function verifierModelFor({ promptModelTouched = false, generatesMedia =
|
|
|
135
137
|
return { model: reasons.length ? 'opus' : 'sonnet', reasons };
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
// Teste e artefato de teste não são superfície de ataque: `e2e/login.spec.ts` não pede a persona security.
|
|
141
|
+
const E2E_DIR_RE = /(^|\/)e2e\//;
|
|
142
|
+
const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
|
|
143
|
+
|
|
138
144
|
export function securityReviewFor(changedFiles = []) {
|
|
139
|
-
const risky = changedFiles
|
|
145
|
+
const risky = changedFiles
|
|
146
|
+
.map((f) => f.replaceAll('\\', '/'))
|
|
147
|
+
.filter((f) => SECURITY_PATH_RE.test(f) && !isTestOnly(f));
|
|
140
148
|
return { required: risky.length > 0, reasons: risky.length ? [`caminho sensível: ${firstOf(risky)}`] : [] };
|
|
141
149
|
}
|
|
142
150
|
|
|
@@ -379,8 +387,3 @@ export function getRiskSignals(signals) {
|
|
|
379
387
|
|
|
380
388
|
return out;
|
|
381
389
|
}
|
|
382
|
-
|
|
383
|
-
export const RISK_CONSTANTS = {
|
|
384
|
-
FORCED_PAUSE_WORK_TYPES,
|
|
385
|
-
E2E_SKIP_CLASSES_THAT_PAUSE,
|
|
386
|
-
};
|
|
@@ -9,17 +9,14 @@ const PROFILES = {
|
|
|
9
9
|
dotnet: {
|
|
10
10
|
isDotnet: true, isNextjs: false,
|
|
11
11
|
defaultValidators: ['packages'],
|
|
12
|
-
buildCommand: 'dotnet build --no-restore',
|
|
13
12
|
},
|
|
14
13
|
nextjs: {
|
|
15
14
|
isDotnet: false, isNextjs: true,
|
|
16
15
|
defaultValidators: ['nextjs-component'],
|
|
17
|
-
buildCommand: 'npx tsc --noEmit',
|
|
18
16
|
},
|
|
19
17
|
empty: {
|
|
20
18
|
isDotnet: false, isNextjs: false,
|
|
21
19
|
defaultValidators: [],
|
|
22
|
-
buildCommand: null,
|
|
23
20
|
},
|
|
24
21
|
};
|
|
25
22
|
|
|
@@ -38,7 +35,6 @@ function mergeProfiles(profiles) {
|
|
|
38
35
|
isDotnet: profiles.some((p) => p.isDotnet),
|
|
39
36
|
isNextjs: profiles.some((p) => p.isNextjs),
|
|
40
37
|
defaultValidators: [...new Set(profiles.flatMap((p) => p.defaultValidators))],
|
|
41
|
-
buildCommand: profiles.map((p) => p.buildCommand).find(Boolean) ?? null,
|
|
42
38
|
};
|
|
43
39
|
}
|
|
44
40
|
|
|
@@ -120,8 +120,11 @@ function auditDotnet(files, read) {
|
|
|
120
120
|
if (major !== null && major < DOTNET_EXPECTED_MAJOR) add('Baixo', `Pacote desatualizado mas compatível (${p} ${packages.get(p)})`, [], `subir para ${DOTNET_EXPECTED_MAJOR}.x`);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
123
|
+
const analyzers = packages.has('Microsoft.CodeAnalysis.BannedApiAnalyzers');
|
|
124
|
+
if (!analyzers) {
|
|
125
|
+
const newGuid = grepFiles(source, read, /Guid\.NewGuid\(\)/);
|
|
126
|
+
if (newGuid.length > 0) add('Alto', '`Guid.NewGuid()`', newGuid, 'trocar por `Guid.CreateVersion7()`');
|
|
127
|
+
}
|
|
125
128
|
const repository = grepFiles(source, read, /interface\s+(IRepository\s*<|IUnitOfWork\b)/);
|
|
126
129
|
if (repository.length > 0) {
|
|
127
130
|
const di = grepFiles(source, read, /Add(Scoped|Transient|Singleton)\s*(<\s*(IRepository|IUnitOfWork)|\(\s*typeof\s*\(\s*(IRepository|IUnitOfWork))/);
|
|
@@ -135,7 +138,7 @@ function auditDotnet(files, read) {
|
|
|
135
138
|
if (serviceLayer.length > 0) add('Alto', 'Camada de Service/Manager entre handler e dados', serviceLayer, 'o handler fala com o `ApplicationDbContext` direto');
|
|
136
139
|
const rawSql = grepFiles(source, read, /\b(FromSqlRaw|ExecuteSqlRaw|SqlQueryRaw|ExecuteQuery)\s*\(/);
|
|
137
140
|
if (rawSql.length > 0) add('Alto', 'SQL cru', rawSql, 'LINQ, ou store dedicado com justificativa em ADR');
|
|
138
|
-
if (projects.length > 0 && !
|
|
141
|
+
if (projects.length > 0 && !analyzers) {
|
|
139
142
|
add('Médio', 'Analyzers do stack ausentes (o build não barra `Guid.NewGuid()` nem token não repassado)', [],
|
|
140
143
|
'`Directory.Build.props` + `BannedSymbols.txt` + `morph.globalconfig` na raiz: `morph-spec standards --show backend-dotnet-analyzers#files`');
|
|
141
144
|
}
|
|
@@ -144,6 +147,7 @@ function auditDotnet(files, read) {
|
|
|
144
147
|
return { rows, deviations };
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
const ESLINT_CONFIGS = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs'];
|
|
147
151
|
const UI_LIBRARIES = ['@mui/material', 'antd', '@chakra-ui/react', 'bootstrap', 'react-bootstrap', '@mantine/core'];
|
|
148
152
|
|
|
149
153
|
function auditNextjs(root, read) {
|
|
@@ -175,11 +179,18 @@ function auditNextjs(root, read) {
|
|
|
175
179
|
rows.push({ layer: 'Roteamento', detected: appRouter ? 'App Router' : (pagesRouter ? 'Pages Router' : 'não detectado'), expected: 'App Router', ok: appRouter || !pagesRouter });
|
|
176
180
|
if (pagesRouter && !appRouter) add('Médio', 'Pages Router em vez de App Router', [next.file], 'migrar as rotas para `app/`');
|
|
177
181
|
rows.push({ layer: 'Estilo', detected: next.deps.tailwindcss ? 'Tailwind' : 'ausente', expected: 'Tailwind', ok: Boolean(next.deps.tailwindcss) });
|
|
178
|
-
|
|
182
|
+
const motionPackages = ['motion', 'framer-motion'].filter((p) => next.deps[p]);
|
|
183
|
+
rows.push({ layer: 'Motion', detected: motionPackages.join(' + ') || 'ausente', expected: 'motion (`motion/react`)', ok: motionPackages.length < 2 });
|
|
184
|
+
if (motionPackages.length === 2) add('Baixo', '`motion` e `framer-motion` instalados juntos', [next.file], 'remover `framer-motion`; o import é `motion/react`');
|
|
179
185
|
const shadcn = existsSync(join(root, base, 'components.json'));
|
|
180
186
|
const otherUi = UI_LIBRARIES.filter((lib) => next.deps[lib]);
|
|
181
187
|
rows.push({ layer: 'UI', detected: otherUi.join(', ') || (shadcn ? 'shadcn' : 'ausente'), expected: 'shadcn', ok: otherUi.length === 0 });
|
|
182
188
|
if (otherUi.length > 0) add('Médio', `Biblioteca de UI diferente (${otherUi.join(', ')})`, [next.file], 'shadcn sobre Tailwind');
|
|
189
|
+
const eslintConfig = ESLINT_CONFIGS.map((name) => `${base}${name}`).find((file) => existsSync(join(root, file)));
|
|
190
|
+
if (!eslintConfig || !/no-restricted-imports/.test(read(eslintConfig) ?? '')) {
|
|
191
|
+
add('Médio', 'ESLint do stack ausente (o lint não barra import proibido nem fetch em `useEffect`)', eslintConfig ? [eslintConfig] : [],
|
|
192
|
+
'`eslint.config.mjs` na raiz do app: `morph-spec standards --show frontend-nextjs-lint#files`');
|
|
193
|
+
}
|
|
183
194
|
return { rows, deviations };
|
|
184
195
|
}
|
|
185
196
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
-
import { join, resolve
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
|
|
5
5
|
import { isInside } from './test-runner.js';
|
|
@@ -284,12 +284,6 @@ export function evalColdCacheWarning(summary) {
|
|
|
284
284
|
return null;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
export function relativeToProject(projectPath, target) {
|
|
288
|
-
const root = resolve(projectPath) + sep;
|
|
289
|
-
const full = resolve(target);
|
|
290
|
-
return full.startsWith(root) ? full.slice(root.length).replace(/\\/g, '/') : full;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
287
|
export function isDirectory(path) {
|
|
294
288
|
try {
|
|
295
289
|
return statSync(path).isDirectory();
|
|
@@ -186,46 +186,3 @@ async function runSingleValidator(validatorId, projectPath, featureName, options
|
|
|
186
186
|
};
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
-
|
|
190
|
-
export function formatValidationResults(result) {
|
|
191
|
-
if (result.passed && result.warnings === 0) {
|
|
192
|
-
const ranCount = Object.keys(result.results || {}).length;
|
|
193
|
-
const countLabel = ranCount > 0 ? ` (${ranCount} validator${ranCount !== 1 ? 's' : ''})` : '';
|
|
194
|
-
console.log(chalk.green(`\n ✅ All validations passed${countLabel}!\n`));
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (result.passed && result.warnings > 0) {
|
|
199
|
-
console.log(chalk.yellow(`\n ⚠️ Passed with ${result.warnings} warning(s)\n`));
|
|
200
|
-
} else {
|
|
201
|
-
console.log(chalk.red(`\n ❌ Validation FAILED: ${result.errors} error(s), ${result.warnings} warning(s)\n`));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const errors = result.issues.filter(i => i.level === 'error');
|
|
205
|
-
const warnings = result.issues.filter(i => i.level === 'warning');
|
|
206
|
-
|
|
207
|
-
if (errors.length > 0) {
|
|
208
|
-
console.log(chalk.red.bold(' Errors:'));
|
|
209
|
-
for (const issue of errors.slice(0, 10)) {
|
|
210
|
-
console.log(chalk.red(` ✗ ${issue.message}`));
|
|
211
|
-
if (issue.file) console.log(chalk.gray(` ${issue.file}${issue.line ? ':' + issue.line : ''}`));
|
|
212
|
-
if (issue.solution) console.log(chalk.cyan(` Fix: ${issue.solution}`));
|
|
213
|
-
}
|
|
214
|
-
if (errors.length > 10) {
|
|
215
|
-
console.log(chalk.gray(` ... and ${errors.length - 10} more errors`));
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (warnings.length > 0) {
|
|
220
|
-
console.log(chalk.yellow('\n Warnings:'));
|
|
221
|
-
for (const issue of warnings.slice(0, 5)) {
|
|
222
|
-
console.log(chalk.yellow(` ⚠ ${issue.message}`));
|
|
223
|
-
if (issue.file) console.log(chalk.gray(` ${issue.file}`));
|
|
224
|
-
}
|
|
225
|
-
if (warnings.length > 5) {
|
|
226
|
-
console.log(chalk.gray(` ... and ${warnings.length - 5} more warnings`));
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
console.log('');
|
|
231
|
-
}
|
|
@@ -3,6 +3,8 @@ import { existsSync, readFileSync, rmSync } from 'fs';
|
|
|
3
3
|
import { join, resolve } from 'path';
|
|
4
4
|
|
|
5
5
|
import { runBuildCheck, getBuildSteps, isDotnetStack } from '../../../framework/hooks/shared/build-check.js';
|
|
6
|
+
import { changedPathsAgainst, detectDefaultBranch } from '../git/default-branch.js';
|
|
7
|
+
import { resolveBaseRef } from '../git/branch-classifier.js';
|
|
6
8
|
import { composeProjectName, runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
|
|
7
9
|
import { buildComposeEnv } from '../e2e/compose-env.js';
|
|
8
10
|
import { e2eApplicability } from '../e2e/applicability.js';
|
|
@@ -218,8 +220,10 @@ async function runBuildNode({ projectPath, stack, paths: declaredPaths = {}, tim
|
|
|
218
220
|
return { status: 'skip', command: null, durationMs: 0, reason: 'requested', output: '' };
|
|
219
221
|
}
|
|
220
222
|
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
+
const resolved = resolveDotnetBuild(projectPath, stack, declaredPaths);
|
|
224
|
+
const { hint } = resolved;
|
|
225
|
+
const paths = { ...resolved.paths, lintFiles: featureLintFiles(projectPath, stack, resolved.paths.frontendPath) };
|
|
226
|
+
const steps = getBuildSteps(stack, { cwd: projectPath, frontendPath: paths.frontendPath, backendPath: paths.backendPath, backendTarget: paths.backendTarget, lintFiles: paths.lintFiles });
|
|
223
227
|
if (steps.length === 0) {
|
|
224
228
|
const reason = stack ? `no build command for stack "${stack}"` : 'no stack configured';
|
|
225
229
|
return { status: 'skip', command: null, durationMs: 0, reason, output: '' };
|
|
@@ -240,6 +244,18 @@ async function runBuildNode({ projectPath, stack, paths: declaredPaths = {}, tim
|
|
|
240
244
|
};
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
const LINTABLE = /\.(?:tsx?|jsx?|mjs|cjs)$/;
|
|
248
|
+
|
|
249
|
+
/** The feature's own source files under the frontend, relative to it; `[]` when the base does not resolve. */
|
|
250
|
+
export function featureLintFiles(projectPath, stack, frontendPath, { changed } = {}) {
|
|
251
|
+
if (!/nextjs|react|vue|node/i.test(stack ?? '')) return [];
|
|
252
|
+
const paths = changed ?? changedPathsAgainst(resolveBaseRef(detectDefaultBranch(projectPath), projectPath), projectPath);
|
|
253
|
+
const prefix = frontendPath ? `${frontendPath.replace(/\\/g, '/').replace(/\/$/, '')}/` : '';
|
|
254
|
+
return paths
|
|
255
|
+
.filter((p) => LINTABLE.test(p) && p.startsWith(prefix) && existsSync(join(projectPath, p)))
|
|
256
|
+
.map((p) => p.slice(prefix.length));
|
|
257
|
+
}
|
|
258
|
+
|
|
243
259
|
function appendBuildFeedback(feedback, buildNode) {
|
|
244
260
|
if (buildNode.status !== 'fail') return;
|
|
245
261
|
for (const line of linesOf(buildNode.output)) {
|
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
import { allocateEnvBlock, allocatedFeatures, portLayout, resolveFleetConfig } from './env-allocator.js';
|
|
13
13
|
|
|
14
14
|
const PROJECT_PROBE = 'probe';
|
|
15
|
-
const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
|
|
15
|
+
export const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
|
|
16
16
|
|
|
17
|
-
function resolveGitCommonDir(cwd) {
|
|
17
|
+
export function resolveGitCommonDir(cwd) {
|
|
18
18
|
try {
|
|
19
19
|
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
20
20
|
cwd, stdio: 'pipe', encoding: 'utf-8', windowsHide: true,
|
|
@@ -57,6 +57,10 @@ function publishedPorts(entry) {
|
|
|
57
57
|
return ports;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
export function stackBelongsTo(entry, root) {
|
|
61
|
+
return belongsToRoot(entry, norm(root));
|
|
62
|
+
}
|
|
63
|
+
|
|
60
64
|
function composeProjectOf(entry) {
|
|
61
65
|
const labels = String(entry?.Labels ?? '');
|
|
62
66
|
for (const part of labels.split(',')) {
|
|
@@ -73,7 +77,7 @@ function containerNameOf(entry) {
|
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
// `null` = unreadable, distinct from `[]` = docker answered with nothing running.
|
|
76
|
-
function parseDockerJson(raw) {
|
|
80
|
+
export function parseDockerJson(raw) {
|
|
77
81
|
const text = String(raw ?? '').trim();
|
|
78
82
|
if (!text) return [];
|
|
79
83
|
const parsed = parseComposePs(text);
|
|
@@ -9,6 +9,22 @@ const DEFAULT_BASE_PORT = 4000;
|
|
|
9
9
|
const DEFAULT_BLOCK_SIZE = 10;
|
|
10
10
|
const MAX_PORT = 65535;
|
|
11
11
|
|
|
12
|
+
// Chromium's kRestrictedPorts (net/base/port_util.cc): the browser refuses them with
|
|
13
|
+
// ERR_UNSAFE_PORT, so a block holding one is a stack Playwright and the verifier cannot open.
|
|
14
|
+
const BROWSER_UNSAFE_PORTS = new Set([
|
|
15
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103,
|
|
16
|
+
104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513,
|
|
17
|
+
514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720,
|
|
18
|
+
1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function blockHasUnsafePort(base, blockSize) {
|
|
22
|
+
for (let port = base; port < base + blockSize; port += 1) {
|
|
23
|
+
if (BROWSER_UNSAFE_PORTS.has(port)) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
12
28
|
export function registryPath(gitCommonDir) {
|
|
13
29
|
return join(gitCommonDir, 'morph-spec', 'env-registry.json');
|
|
14
30
|
}
|
|
@@ -47,23 +63,29 @@ export function resolveFleetConfig(rootPath) {
|
|
|
47
63
|
}
|
|
48
64
|
}
|
|
49
65
|
|
|
50
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* First free index wins, so released slots are reused instead of creeping toward the port ceiling.
|
|
68
|
+
* A block with a browser-unsafe port is never handed out, and one recorded before this rule is moved.
|
|
69
|
+
*/
|
|
51
70
|
export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BASE_PORT, blockSize = DEFAULT_BLOCK_SIZE }) {
|
|
52
71
|
const registry = readRegistry(gitCommonDir);
|
|
53
72
|
|
|
54
73
|
const existing = registry.allocations[feature];
|
|
55
74
|
if (existing && Number.isInteger(existing.index)) {
|
|
56
75
|
const base = basePort + existing.index * blockSize;
|
|
57
|
-
|
|
76
|
+
if (!blockHasUnsafePort(base, blockSize)) {
|
|
77
|
+
return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
|
|
78
|
+
}
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
const used = new Set(
|
|
61
|
-
Object.
|
|
62
|
-
.
|
|
82
|
+
Object.entries(registry.allocations)
|
|
83
|
+
.filter(([name]) => name !== feature)
|
|
84
|
+
.map(([, a]) => a?.index)
|
|
63
85
|
.filter((i) => Number.isInteger(i)),
|
|
64
86
|
);
|
|
65
87
|
let index = 0;
|
|
66
|
-
while (used.has(index)) index += 1;
|
|
88
|
+
while (used.has(index) || blockHasUnsafePort(basePort + index * blockSize, blockSize)) index += 1;
|
|
67
89
|
|
|
68
90
|
const base = basePort + index * blockSize;
|
|
69
91
|
if (base + blockSize - 1 > MAX_PORT) {
|
|
@@ -18,6 +18,14 @@ const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
|
|
|
18
18
|
const REPLICABLE_MAX_FILES = 500;
|
|
19
19
|
const REPLICABLE_MAX_BYTES = 8 * 1024 * 1024;
|
|
20
20
|
|
|
21
|
+
// Narrower than the build set on purpose: an untracked `bin/` may be new code, a `test-results/` never is.
|
|
22
|
+
const TEST_ARTIFACT_DIR_NAMES = new Set(['test-results', 'TestResults', 'playwright-report', 'blob-report']);
|
|
23
|
+
|
|
24
|
+
/** @param {string} p - forward-slash relative path */
|
|
25
|
+
export function isTestArtifact(p) {
|
|
26
|
+
return p.split('/').filter(Boolean).some((seg) => TEST_ARTIFACT_DIR_NAMES.has(seg));
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
/** @param {string} p - forward-slash relative path */
|
|
22
30
|
export function isDisposableBuildArtifact(p) {
|
|
23
31
|
if (DISPOSABLE_FILE_RE.test(p)) return true;
|
|
@@ -281,27 +281,23 @@ export const DISPATCH_CONTRACT = [
|
|
|
281
281
|
'o caminho do relatório e, se houver, o worktree. O resto é este contrato.',
|
|
282
282
|
'',
|
|
283
283
|
'1. RELATÓRIO: GRAVE o arquivo de relatório que o dispatch nomeia (`.morph/features/{feature}/3-implement/reports/F{n}.md`) ' +
|
|
284
|
-
'ANTES de encerrar — é o retorno autoritativo, não a sua mensagem final.
|
|
285
|
-
'2. ESTADO: NÃO toque em `plan.md`, `checks.json`, `feature.json
|
|
284
|
+
'ANTES de encerrar — é o retorno autoritativo, não a sua mensagem final.',
|
|
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
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
288
|
'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório; se falhar, corrija e rode só os que falharam. ' +
|
|
289
289
|
'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
290
|
'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
|
-
'4.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
'não repita nem espere em laço — diga no relatório o que não rodou.',
|
|
297
|
-
'7. REUSO: antes de criar tipo, componente, hook ou serviço NOVO, verifique se já existe: `morph-spec graph explain <nome>` e ' +
|
|
291
|
+
'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 — ' +
|
|
292
|
+
'e só os seus arquivos (`git add <paths>`, nunca diretório). Sem commit, a retomada recomeça do zero.',
|
|
293
|
+
'5. PROCESSOS: encerre dev server pelo PID que você mesmo subiu. Build e suíte pesados rodam por `morph-spec heavy run -- <comando>`, em primeiro plano; ' +
|
|
294
|
+
'exit 3 é fila cheia: não repita nem espere em laço, diga no relatório o que não rodou.',
|
|
295
|
+
'6. REUSO: antes de criar tipo, componente, hook ou serviço NOVO, verifique se já existe: `morph-spec graph explain <nome>` e ' +
|
|
298
296
|
'`graph affected <conceito> --uses-only`; sem grafo (`no-graph`), caia para `grep -rn "<nome>"` e siga — a ausência de grafo nunca bloqueia. ' +
|
|
299
297
|
'Se criar mesmo assim, diga por que em uma linha no relatório.',
|
|
300
|
-
'
|
|
301
|
-
|
|
302
|
-
'9.
|
|
303
|
-
'os guards de escrita só enxergam Write/Edit.',
|
|
304
|
-
'10. BLOQUEIO: decisão que não é sua (requisito contra o plano ou um ADR, escolha deixada ao humano) → `BLOQUEADO` na 1ª linha, ' +
|
|
298
|
+
'7. MUTAÇÃO: nunca altere, reverta, mova ou apague código de produção para ver um teste falhar; a prova de que o teste morde é do verificador.',
|
|
299
|
+
'8. ESCRITA: código e teste do projeto só por Write/Edit, nunca pelo Bash (heredoc, `sed -i`, script que grava): os guards só enxergam Write/Edit.',
|
|
300
|
+
'9. BLOQUEIO: decisão que não é sua (requisito contra o plano ou um ADR, escolha deixada ao humano) → `BLOQUEADO` na 1ª linha, ' +
|
|
305
301
|
'com a pergunta e as opções. Dificuldade técnica não é bloqueio.',
|
|
306
302
|
'',
|
|
307
303
|
'Formato do relatório (o `report-check` lê estas marcas):',
|
|
@@ -416,11 +412,8 @@ function buildBody(agent, maxTurns) {
|
|
|
416
412
|
'',
|
|
417
413
|
'## Working fast',
|
|
418
414
|
'',
|
|
419
|
-
'Independent tool calls go in the SAME response: read every listed file at once, fire the
|
|
420
|
-
'
|
|
421
|
-
'(load a deferred `mcp__` tool with ToolSearch). Write the tests the budget asks for, then run only your own ' +
|
|
422
|
-
'filter ONCE before the report — no red→green cycles, no `morph-spec verify` (the orchestrator proves it). ' +
|
|
423
|
-
'Never alter, revert or move production code to watch a test fail: mutation proof belongs to the Gate 3 verifier.',
|
|
415
|
+
'Independent tool calls go in the SAME response: read every listed file at once, fire the searches together. ' +
|
|
416
|
+
'Check a library API in the `context7` MCP before guessing (load a deferred `mcp__` tool with ToolSearch).',
|
|
424
417
|
);
|
|
425
418
|
}
|
|
426
419
|
|
|
@@ -7,34 +7,6 @@ const MORPH_MANAGED_KEY = '_morph_managed';
|
|
|
7
7
|
export { installClaudeHooks, installGlobalStatusline, getHooksVersion } from './hooks-installer.js';
|
|
8
8
|
export { installAgents } from './agents-installer.js';
|
|
9
9
|
|
|
10
|
-
export async function installEnvVars(targetPath, envVars) {
|
|
11
|
-
const settings = await loadSettings(targetPath);
|
|
12
|
-
|
|
13
|
-
settings.env = settings.env || {};
|
|
14
|
-
|
|
15
|
-
const added = [];
|
|
16
|
-
for (const [key, value] of Object.entries(envVars)) {
|
|
17
|
-
if (!(key in settings.env)) {
|
|
18
|
-
settings.env[key] = value;
|
|
19
|
-
added.push(key);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
settings[MORPH_MANAGED_KEY] = settings[MORPH_MANAGED_KEY] || {};
|
|
24
|
-
settings[MORPH_MANAGED_KEY].env_added =
|
|
25
|
-
settings[MORPH_MANAGED_KEY].env_added || [];
|
|
26
|
-
for (const key of added) {
|
|
27
|
-
if (!settings[MORPH_MANAGED_KEY].env_added.includes(key)) {
|
|
28
|
-
settings[MORPH_MANAGED_KEY].env_added.push(key);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
settings[MORPH_MANAGED_KEY].generated_at = new Date().toISOString();
|
|
32
|
-
|
|
33
|
-
await saveSettings(targetPath, settings);
|
|
34
|
-
|
|
35
|
-
return { added, total: Object.keys(settings.env).length };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
10
|
export async function resetMorphSettings(targetPath) {
|
|
39
11
|
const claudeDir = join(targetPath, '.claude');
|
|
40
12
|
const settingsPath = join(claudeDir, 'settings.local.json');
|