@polymorphism-tech/morph-spec 8.43.0 → 8.44.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.
Files changed (38) hide show
  1. package/CLAUDE.md +6 -4
  2. package/README.md +2 -2
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/docs/CLI.md +13 -8
  7. package/framework/hooks/claude-code/core/worktree-env-inject.js +1 -4
  8. package/framework/rules/ai-agents-standards.md +1 -0
  9. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  10. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  11. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  12. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  13. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  14. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  15. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  16. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  17. package/framework/skills/core/morph-media/SKILL.md +1 -1
  18. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  19. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  20. package/framework/skills/core/morph-review/SKILL.md +1 -1
  21. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  22. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  23. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  24. package/framework/standards/STANDARDS.json +45 -1
  25. package/framework/standards/ai-agents/tool-adapters.md +226 -0
  26. package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
  27. package/package.json +1 -1
  28. package/src/commands/project/worktree.js +34 -16
  29. package/src/commands/state/approve.js +2 -1
  30. package/src/commands/state/finish.js +4 -0
  31. package/src/commands/state/gate-check.js +2 -1
  32. package/src/commands/validation/verify.js +13 -21
  33. package/src/lib/e2e/compose-env.js +5 -2
  34. package/src/lib/git/evidence-staleness.js +39 -0
  35. package/src/lib/risk-detector.js +3 -3
  36. package/src/lib/tasks/eval-node.js +4 -34
  37. package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
  38. package/src/lib/worktree/env-allocator.js +35 -5
@@ -15,8 +15,8 @@
15
15
  * - e2eStatus + e2eReason + gate — nó e2e do último verify. Um `skip`
16
16
  * cuja razão NÃO seja dispensa legítima pausa SÓ no ship (gate 'review').
17
17
  * Sem escopo (stamp sem o campo) cai do mesmo lado seguro que `unknown`
18
- * - verificationStale + gate: bool/str — stamp com sha != HEAD pausa SÓ no
19
- * ship (gate 'review') — código mudou depois do último verify
18
+ * - verificationStale + gate: bool/str — stamp de um commit cujo código já mudou
19
+ * (fora de .morph/) pausa SÓ no ship (gate 'review')
20
20
  * - evalScoreSource + gate: str — no ship, score que NÃO veio do avaliador
21
21
  * independente pausa (auto-avaliação não decide o Gate 3)
22
22
  * - mutationProof + gate: object — { section, min, productionCode } lido do
@@ -274,7 +274,7 @@ export function shouldPauseGate(config, signals) {
274
274
  if (signals.gate === 'review' && signals.verificationStale === true) {
275
275
  return {
276
276
  pause: true,
277
- reason: 'verification stamp desatualizado (sha difere do HEAD) — rode `morph-spec verify` de novo antes do ship',
277
+ reason: 'verification stamp desatualizado (o código mudou depois do commit do stamp) — rode `morph-spec verify` de novo antes do ship',
278
278
  };
279
279
  }
280
280
 
@@ -36,7 +36,7 @@ import { join, resolve, sep } from 'path';
36
36
  import { tmpdir } from 'os';
37
37
 
38
38
  import { isInside } from './test-runner.js';
39
- import { readAiPin } from '../standards/verified-against.js';
39
+ import { resolveAiPin } from '../ai/pin-drift.js';
40
40
 
41
41
  /** The runner kind. One spelling, imported by every reader. */
42
42
  export const EVAL_RUNNER = 'evals';
@@ -72,34 +72,6 @@ export function cacheRelevantPackages(packages) {
72
72
  return out;
73
73
  }
74
74
 
75
- /**
76
- * Read the pin from a project, wherever the framework lives.
77
- *
78
- * Two roots, ONE reader: this repository carries `framework/ai-pin.json` at the
79
- * top; an installed project carries it under `.morph/framework/`. `readAiPin`
80
- * appends `framework/ai-pin.json` to whatever root it is given, so both cases
81
- * are the same function with a different argument — never a second parser.
82
- *
83
- * Returns null when neither exists: a project with no pin simply has no stamp
84
- * ruler, and refusing to run there would make the eval node unusable outside
85
- * this framework's own stack.
86
- *
87
- * @param {string} projectPath
88
- * @returns {{pinnedAt: string, packages: object}|null}
89
- */
90
- export function readProjectPin(projectPath) {
91
- for (const root of [projectPath, join(projectPath, '.morph')]) {
92
- try {
93
- return readAiPin(root);
94
- } catch {
95
- // Next candidate. The throw is readAiPin's contract for "no pin here",
96
- // and it is the reason this loop exists instead of an existsSync probe
97
- // that would duplicate the path knowledge.
98
- }
99
- }
100
- return null;
101
- }
102
-
103
75
  /**
104
76
  * Resolve every path an eval node names, against the project root.
105
77
  *
@@ -256,11 +228,9 @@ export function evalPreflight(projectPath, node, options = {}) {
256
228
  return { status: 'fail', reason: `eval-cache-empty: ${cacheRoot} não contém nenhuma entrada de cache` };
257
229
  }
258
230
 
259
- const pin = readProjectPin(projectPath);
231
+ const pin = resolveAiPin(projectPath);
260
232
  if (!pin) {
261
- // No pin, no ruler — and a project without one is a legitimate case, not a
262
- // failure: the stamp check simply does not apply. Saying so is the point;
263
- // silently treating it as "stamp ok" would be the same thing said badly.
233
+ // No pin in the project or in the CLI package: nothing to stamp against.
264
234
  return { status: null, reason: null };
265
235
  }
266
236
 
@@ -315,7 +285,7 @@ export function evalNodeEnv(node, projectPath, options = {}) {
315
285
  MORPH_EVAL_ENVFILE: envFile,
316
286
  };
317
287
 
318
- const pin = readProjectPin(projectPath);
288
+ const pin = resolveAiPin(projectPath);
319
289
  if (pin) {
320
290
  // Compact, and only the packages that decide the cache format. The fixture
321
291
  // stamps from THIS, so it never has to read (or parse) the pin itself.
@@ -1,56 +1,21 @@
1
- /**
2
- * Contract Compliance Validator
3
- *
4
- * Parses contracts.cs from feature outputs and verifies that the project code
5
- * actually implements all defined interfaces, methods, DTOs, and enums.
6
- *
7
- * MORPH-SPEC 3.0 - Phase A: Active Validation Pipeline
8
- */
9
-
10
1
  import { readFileSync, existsSync } from 'fs';
11
2
  import { join } from 'path';
12
3
  import { glob } from 'glob';
13
4
  import chalk from 'chalk';
14
5
  import { getAbsoluteOutputPath, resolveContractsOutputType } from '../../../core/paths/output-schema.js';
15
6
 
16
- /**
17
- * Validate that contracts.cs interfaces are implemented in project code
18
- *
19
- * @param {string} projectPath - Project root path
20
- * @param {string} featureName - Feature name
21
- * @param {Object} options - Options
22
- * @returns {Object} { status, errors, warnings, issues, coverage }
23
- */
24
7
  export async function validateContracts(projectPath, featureName, options = {}) {
25
- // `'contracts'` is not an output type — `getAbsoluteOutputPath` threw
26
- // "Unknown output type" on every call. The runner already resolves this the
27
- // right way before deciding to schedule this validator
28
- // (`resolveContractsOutputType()`); using the same resolver here is what
29
- // keeps the two from disagreeing again.
30
8
  const contractsPath = getAbsoluteOutputPath(projectPath, featureName, resolveContractsOutputType());
31
9
 
32
- if (!existsSync(contractsPath)) {
33
- return {
34
- status: 'ok',
35
- errors: 0,
36
- warnings: 0,
37
- issues: [],
38
- coverage: null,
39
- message: 'No contracts.cs found — skipping'
40
- };
41
- }
10
+ const skipped = (message) => ({ status: 'ok', errors: 0, warnings: 0, issues: [], coverage: null, message });
11
+
12
+ if (!existsSync(contractsPath)) return skipped('No spec.md found — skipping');
42
13
 
43
14
  const contractsContent = readFileSync(contractsPath, 'utf-8');
44
15
  const contracts = parseContracts(contractsContent);
45
16
 
46
17
  if (contracts.interfaces.length === 0 && contracts.records.length === 0 && contracts.enums.length === 0) {
47
- return {
48
- status: 'ok',
49
- errors: 0,
50
- warnings: 1,
51
- issues: [{ level: 'warning', message: 'contracts.cs exists but no interfaces/records/enums found' }],
52
- coverage: null
53
- };
18
+ return skipped('spec.md declares no interfaces/records/enums — skipping');
54
19
  }
55
20
 
56
21
  // Find all .cs files in the project (excluding .morph outputs, bin, obj)
@@ -168,9 +133,6 @@ export async function validateContracts(projectPath, featureName, options = {})
168
133
  };
169
134
  }
170
135
 
171
- /**
172
- * Parse contracts.cs content and extract interfaces, records, and enums
173
- */
174
136
  function parseContracts(content) {
175
137
  const interfaces = [];
176
138
  const records = [];
@@ -168,10 +168,10 @@ export const PORT_OFFSETS = Object.freeze({ web: 0, api: 1, postgres: 2, redis:
168
168
  * not in the root's. Every value is a string (env-safe); key order is stable.
169
169
  *
170
170
  * @param {{ base: number, range: [number, number] }} block
171
- * @param {{ stack?: string, feature?: string }} [opts]
171
+ * @param {{ stack?: string, feature?: string, aliases?: Record<string, string> }} [opts] - aliases: resolvePortAliases()
172
172
  * @returns {Record<string,string>}
173
173
  */
174
- export function portLayout(block, { stack = '', feature = '' } = {}) {
174
+ export function portLayout(block, { stack = '', feature = '', aliases = {} } = {}) {
175
175
  const env = {};
176
176
  if (feature) env.COMPOSE_PROJECT_NAME = composeProjectName(feature);
177
177
  env.MORPH_PORT_BASE = String(block.base);
@@ -183,9 +183,39 @@ export function portLayout(block, { stack = '', feature = '' } = {}) {
183
183
  if (/\bdotnet\b/i.test(String(stack))) {
184
184
  env.ASPNETCORE_URLS = `http://localhost:${block.base + PORT_OFFSETS.api}`;
185
185
  }
186
+ for (const [alias, port] of Object.entries(aliases)) env[alias] = String(block.base + PORT_OFFSETS[port]);
186
187
  return env;
187
188
  }
188
189
 
190
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
191
+ const RESERVED_ENV_NAMES = new Set([
192
+ ...Object.keys(portLayout({ base: 0, range: [0, 0] }, { stack: 'dotnet', feature: 'x' })),
193
+ 'PLAYWRIGHT_BASE_URL',
194
+ 'MORPH_FEATURE',
195
+ ]);
196
+
197
+ // e2e.portEnvNames: extra env names for the same four ports, so a compose file reading
198
+ // ${PG_PORT} is fed by the block, not by its own default. An entry that cannot be
199
+ // honoured is dropped and named in `problems`.
200
+ export function resolvePortAliases(config) {
201
+ const declared = config?.e2e?.portEnvNames;
202
+ const aliases = {};
203
+ const problems = [];
204
+ if (declared === undefined || declared === null) return { aliases, problems };
205
+ if (typeof declared !== 'object' || Array.isArray(declared)) {
206
+ return { aliases, problems: [`e2e.portEnvNames deve ser um objeto { ${Object.keys(PORT_OFFSETS).join('|')}: "NOME" }`] };
207
+ }
208
+ for (const [port, name] of Object.entries(declared)) {
209
+ const at = `e2e.portEnvNames.${port}`;
210
+ if (!Object.hasOwn(PORT_OFFSETS, port)) problems.push(`${at}: porta desconhecida (use ${Object.keys(PORT_OFFSETS).join('|')})`);
211
+ else if (typeof name !== 'string' || !ENV_NAME.test(name)) problems.push(`${at}: ${JSON.stringify(name)} não é um nome de variável de ambiente válido`);
212
+ else if (RESERVED_ENV_NAMES.has(name)) problems.push(`${at}: "${name}" já é uma variável do layout`);
213
+ else if (Object.hasOwn(aliases, name)) problems.push(`${at}: "${name}" já é o alias de ${aliases[name]}`);
214
+ else aliases[name] = port;
215
+ }
216
+ return { aliases, problems };
217
+ }
218
+
189
219
  /**
190
220
  * Render the `.morph/worktree.env` contents for a block: a header, the
191
221
  * feature, and exactly the `portLayout()` pairs — nothing else, so the file
@@ -199,15 +229,15 @@ export function portLayout(block, { stack = '', feature = '' } = {}) {
199
229
  * @param {string} feature - the env-registry key: `<feature>` or `task/<name>`
200
230
  * @param {{ base: number, range: [number, number] }} block
201
231
  * @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
202
- * @param {{ morphFeature?: string|null }} [opts]
232
+ * @param {{ morphFeature?: string|null, aliases?: Record<string, string> }} [opts]
203
233
  * @returns {string}
204
234
  */
205
- export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null } = {}) {
235
+ export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null, aliases = {} } = {}) {
206
236
  const lines = [
207
237
  `# Managed by morph-spec — ambiente dedicado desta worktree (feature: ${feature}).`,
208
238
  '# Derivado, git-invisível, regenerado por `worktree setup|link`; nunca editar nem commitar.',
209
239
  `MORPH_FEATURE=${morphFeature || feature}`,
210
- ...Object.entries(portLayout(block, { stack, feature })).map(([k, v]) => `${k}=${v}`),
240
+ ...Object.entries(portLayout(block, { stack, feature, aliases })).map(([k, v]) => `${k}=${v}`),
211
241
  ];
212
242
  return `${lines.join('\n')}\n`;
213
243
  }