@polymorphism-tech/morph-spec 8.45.0 → 8.47.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 (146) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -4
  4. package/bin/validate.js +4 -25
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +2 -2
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +2 -2
  9. package/framework/MORPH.md +9 -9
  10. package/framework/commands/create-umbraco-lp.md +2 -0
  11. package/framework/commands/migrate-lp-to-umbraco.md +2 -0
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +1 -1
  14. package/framework/commands/morph-infra.md +17 -203
  15. package/framework/commands/morph-media.md +3 -0
  16. package/framework/commands/morph-preflight.md +5 -28
  17. package/framework/commands/morph-proposal.md +2 -2
  18. package/framework/commands/morph-troubleshoot.md +2 -14
  19. package/framework/docs/CLI.md +8 -7
  20. package/framework/hooks/claude-code/core/state-sync.js +13 -1
  21. package/framework/hooks/shared/e2e-check.js +3 -4
  22. package/framework/hooks/shared/state-writer.js +1 -0
  23. package/framework/rules/ai-agents-standards.md +18 -16
  24. package/framework/rules/asaas-standards.md +3 -1
  25. package/framework/rules/csharp-standards.md +10 -8
  26. package/framework/rules/data-standards.md +5 -5
  27. package/framework/rules/dotnet-templates.md +1 -1
  28. package/framework/rules/frontend-standards.md +4 -2
  29. package/framework/rules/gsap-standards.md +10 -5
  30. package/framework/rules/hangfire-standards.md +3 -1
  31. package/framework/rules/infrastructure-standards.md +4 -2
  32. package/framework/rules/morph-workflow.md +2 -2
  33. package/framework/rules/neon-auth-standards.md +3 -1
  34. package/framework/rules/neon-standards.md +6 -4
  35. package/framework/rules/nextjs-standards.md +13 -11
  36. package/framework/rules/nextjs-templates.md +1 -1
  37. package/framework/rules/resend-standards.md +3 -1
  38. package/framework/schemas/config.schema.json +4 -0
  39. package/framework/schemas/feature.schema.json +4 -4
  40. package/framework/skills/README.md +1 -1
  41. package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
  42. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
  43. package/framework/skills/core/morph-apply/SKILL.md +3 -3
  44. package/framework/skills/core/morph-brainstorming/SKILL.md +3 -3
  45. package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
  46. package/framework/skills/core/morph-implement/SKILL.md +3 -3
  47. package/framework/skills/core/morph-machine/SKILL.md +3 -3
  48. package/framework/skills/core/morph-media/SKILL.md +3 -3
  49. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  50. package/framework/skills/core/morph-proposal/SKILL.md +3 -3
  51. package/framework/skills/core/morph-review/SKILL.md +30 -17
  52. package/framework/skills/core/morph-stack-scan/SKILL.md +3 -3
  53. package/framework/skills/core/morph-standards/SKILL.md +6 -6
  54. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  55. package/framework/standards/STANDARDS.json +59 -56
  56. package/framework/standards/ai-agents/evals-with-cache.md +1 -1
  57. package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
  58. package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
  59. package/framework/standards/ai-agents/prompt-sources.md +6 -4
  60. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  61. package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
  62. package/framework/standards/ai-agents/setup.md +1 -1
  63. package/framework/standards/ai-agents/structured-output.md +1 -1
  64. package/framework/standards/ai-agents/sweet-spot.md +1 -1
  65. package/framework/standards/architecture/code-comments.md +2 -2
  66. package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
  67. package/framework/standards/backend/api/validation.md +135 -47
  68. package/framework/standards/backend/database/postgresql/database.md +11 -7
  69. package/framework/standards/backend/database/vector-search-rag.md +207 -324
  70. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
  71. package/framework/standards/backend/dotnet/result-pattern.md +40 -6
  72. package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
  73. package/framework/standards/backend/dotnet/scrutor.md +1 -1
  74. package/framework/standards/backend/dotnet/testing.md +1 -1
  75. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
  76. package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
  77. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
  78. package/framework/standards/data/nosql/cache/redis.md +120 -44
  79. package/framework/standards/data/nosql/cosmos-db.md +114 -71
  80. package/framework/standards/frontend/design-system/animations.md +1 -1
  81. package/framework/standards/frontend/design-system/naming.md +1 -1
  82. package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
  83. package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
  84. package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
  85. package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
  86. package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
  87. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
  88. package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
  89. package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
  90. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
  91. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
  92. package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
  93. package/framework/templates/infrastructure/github/README.md +2 -1
  94. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
  95. package/package.json +1 -1
  96. package/src/commands/observability/telemetry.js +3 -5
  97. package/src/commands/project/init.js +2 -1
  98. package/src/commands/project/update.js +16 -18
  99. package/src/commands/state/approve.js +17 -0
  100. package/src/commands/state/gate-check.js +4 -14
  101. package/src/commands/validation/verify.js +99 -57
  102. package/src/core/paths/output-schema.js +1 -1
  103. package/src/dashboard/app/App.jsx +0 -2
  104. package/src/dashboard/app/components/CommandPalette.jsx +2 -3
  105. package/src/dashboard/app/components/EntityLink.jsx +0 -1
  106. package/src/dashboard/app/components/Markdown.jsx +1 -1
  107. package/src/dashboard/app/components/Sidebar.jsx +1 -2
  108. package/src/dashboard/app/pages/RulesMap.jsx +3 -3
  109. package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
  110. package/src/dashboard/scanner/activity-scanner.js +1 -1
  111. package/src/dashboard/scanner/docs-scanner.js +5 -23
  112. package/src/dashboard/scanner/features-scanner.js +1 -1
  113. package/src/dashboard/scanner/index.js +0 -2
  114. package/src/dashboard/scanner/rules-scanner.js +15 -10
  115. package/src/dashboard/scanner/skills-scanner.js +2 -3
  116. package/src/lib/ci/ci-verify.js +118 -0
  117. package/src/lib/ci/github-ci.js +192 -0
  118. package/src/lib/ci/workflow-scaffold.js +122 -0
  119. package/src/lib/evaluator-report.js +13 -0
  120. package/src/lib/risk-detector.js +8 -0
  121. package/src/lib/stack/stack-profile.js +1 -1
  122. package/src/lib/telemetry/verify-event.js +3 -2
  123. package/src/lib/validators/reuse/reuse-validator.js +2 -3
  124. package/src/lib/validators/reuse/symbol-scanner.js +2 -4
  125. package/src/lib/validators/validation-runner.js +8 -88
  126. package/src/lib/validators/verify-runner.js +22 -72
  127. package/src/scripts/setup-infra.js +33 -22
  128. package/src/utils/agents-installer.js +20 -8
  129. package/src/utils/file-copier.js +7 -5
  130. package/framework/commands/commit.md +0 -166
  131. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  132. package/framework/rubrics/backend.json +0 -41
  133. package/framework/rubrics/default.json +0 -36
  134. package/framework/rubrics/frontend.json +0 -36
  135. package/framework/templates/project/validate.js +0 -97
  136. package/src/dashboard/app/pages/Rubrics.jsx +0 -97
  137. package/src/dashboard/scanner/rubrics-scanner.js +0 -12
  138. package/src/lib/validators/architecture/architecture-validator.js +0 -270
  139. package/src/lib/validators/architecture/index.js +0 -7
  140. package/src/lib/validators/css/css-validator.js +0 -340
  141. package/src/lib/validators/css/index.js +0 -7
  142. package/src/lib/validators/design-system/design-system-validator.js +0 -237
  143. package/src/lib/validators/design-system/index.js +0 -7
  144. package/src/lib/validators/score-aggregator.js +0 -244
  145. package/src/lib/validators/ui/index.js +0 -7
  146. package/src/lib/validators/ui/ui-contrast-validator.js +0 -422
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The reading material of the framework — the charter, the runtime
3
- * instructions, the CLI reference and the rubrics the evaluator scores with —
4
- * for the dashboard's Guide and Rubrics pages.
3
+ * instructions, the CLI reference and the Gate 3 evaluator checklist — for the
4
+ * dashboard's Guide page.
5
5
  */
6
- import { readFileSync, readdirSync, existsSync } from 'fs';
6
+ import { readFileSync } from 'fs';
7
7
  import { join } from 'path';
8
8
 
9
9
  function read(path) {
@@ -23,26 +23,11 @@ export function headingsOf(content) {
23
23
  return out;
24
24
  }
25
25
 
26
- function titleOf(content, fallback) {
27
- const h = headingsOf(content || '').find((x) => x.depth === 1);
28
- return h?.text || fallback;
29
- }
30
-
31
- function mdDir(dir) {
32
- if (!existsSync(dir)) return [];
33
- return readdirSync(dir)
34
- .filter((f) => f.endsWith('.md'))
35
- .sort()
36
- .map((f) => {
37
- const content = read(join(dir, f)) || '';
38
- return { id: f.replace(/\.md$/, ''), file: f, title: titleOf(content, f.replace(/\.md$/, '')), content };
39
- });
40
- }
41
-
42
26
  const GUIDE = [
43
27
  { id: 'morph', path: 'MORPH.md', title: 'Carta do harness (MORPH.md)', summary: 'Como o Claude trabalha: papéis, fases, gates, sub-agents. É a fonte única do comportamento.' },
44
28
  { id: 'runtime', path: 'CLAUDE.md', title: 'Instruções de runtime (CLAUDE.md)', summary: 'O que é injetado no CLAUDE.md do projeto: como começar cada sessão, regras críticas, comandos.' },
45
29
  { id: 'cli', path: 'docs/CLI.md', title: 'Referência do CLI', summary: 'Todos os comandos morph-spec: gates, verify, worktrees, grafo, telemetria.' },
30
+ { id: 'review-checklist', path: 'evals/review-checklist.md', title: 'Checklist do avaliador (Gate 3)', summary: 'O que o avaliador independente procura antes do ship: veredito SHIP/FIX e bloqueador com evidência.' },
46
31
  ];
47
32
 
48
33
  export function scanDocs(frameworkDir) {
@@ -51,8 +36,5 @@ export function scanDocs(frameworkDir) {
51
36
  return { ...g, content, headings: headingsOf(content).filter((h) => h.depth <= 3) };
52
37
  }).filter((g) => g.content);
53
38
 
54
- return {
55
- guide,
56
- rubrics: mdDir(join(frameworkDir, 'evals')).filter((d) => d.id === 'review-checklist'),
57
- };
39
+ return { guide };
58
40
  }
@@ -310,7 +310,7 @@ export function scanFeatures(projectDir, { resolveTree = resolveFeatureTree } =
310
310
  }
311
311
 
312
312
  /**
313
- * One feature in full: summary + tasks with their verify/score, the gate
313
+ * One feature in full: summary + tasks with their verify, the gate
314
314
  * decisions, the verify nodes and the document tree.
315
315
  */
316
316
  export function readFeatureDetail(projectDir, name, { resolveTree = resolveFeatureTree, known } = {}) {
@@ -4,7 +4,6 @@ import { scanHooks } from './hooks-scanner.js';
4
4
  import { scanSkills } from './skills-scanner.js';
5
5
  import { scanStandards } from './standards-scanner.js';
6
6
  import { scanRules } from './rules-scanner.js';
7
- import { scanRubrics } from './rubrics-scanner.js';
8
7
  import { scanDocs } from './docs-scanner.js';
9
8
 
10
9
  /**
@@ -21,7 +20,6 @@ export function scanAll(frameworkDir) {
21
20
  skills: scanSkills(frameworkDir, phases.list),
22
21
  standards: scanStandards(frameworkDir, agents.list),
23
22
  rules: scanRules(frameworkDir),
24
- rubrics: scanRubrics(frameworkDir),
25
23
  docs: scanDocs(frameworkDir),
26
24
  };
27
25
  }
@@ -18,17 +18,22 @@ function parseFrontmatter(content) {
18
18
  return { paths, stacks };
19
19
  }
20
20
 
21
- /**
22
- * Standards a rule imports, each once. RulesMap keys an edge per ref, so a
23
- * rule that imports the same standard twice (infrastructure-standards.md did,
24
- * up to 8.38.0) drew two edges under one React key.
25
- */
21
+ const ROUTE_LINE = /^- \*\*([a-z0-9-]+)\*\* \(`([^`]+\.md)`\)/;
22
+
23
+ // Bullet form: - **<id>** (`<path under standards/>`) — <digest>. An `@path`
24
+ // import inside .claude/rules resolves relative to the rule file and never loads.
25
+ export function parseStandardRoutes(content) {
26
+ const routes = [];
27
+ for (const line of content.split(/\r?\n/)) {
28
+ const m = ROUTE_LINE.exec(line);
29
+ if (m) routes.push({ id: m[1], path: m[2] });
30
+ }
31
+ return routes;
32
+ }
33
+
34
+ // RulesMap keys one edge per ref, so a repeated path would collide.
26
35
  export function extractStandardRefs(content) {
27
- const refs = new Set();
28
- const regex = /@\.morph\/framework\/standards\/(.+\.md)/g;
29
- let m;
30
- while ((m = regex.exec(content)) !== null) refs.add(m[1]);
31
- return [...refs];
36
+ return [...new Set(parseStandardRoutes(content).map((r) => r.path))];
32
37
  }
33
38
 
34
39
  export function scanRules(frameworkDir) {
@@ -84,13 +84,12 @@ export function scanSkills(frameworkDir, phases = []) {
84
84
  if (!existsSync(skillPath)) continue;
85
85
  const content = readFileSync(skillPath, 'utf8');
86
86
  const fm = parseFrontmatter(content);
87
- // The framework spells it `user-invokable`; the Claude Code docs say `user-invocable`.
88
- const invocable = fm['user-invokable'] ?? fm['user-invocable'];
87
+ const invocable = fm['user-invocable'];
89
88
  skills.push({
90
89
  id: skillDir.name,
91
90
  name: fm.name || skillDir.name,
92
91
  description: fm.description || '',
93
- whenToInvoke: fm['when-to-invoke'] || '',
92
+ whenToUse: fm.when_to_use || '',
94
93
  level: levelDir.name,
95
94
  userInvocable: invocable === true || invocable === 'true',
96
95
  stacks: typeof fm.stacks === 'string' ? fm.stacks.replace(/[[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean) : [],
@@ -0,0 +1,118 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { getHeadSha } from '../git/default-branch.js';
5
+ import { treeMovedSince } from '../git/evidence-staleness.js';
6
+ import { resolveGh } from '../git/gh.js';
7
+ import {
8
+ CI_WORKFLOW_DIR, CI_WORKFLOW_FILE, ciAvailability, downloadResult, findCiWorkflow, pushBranch,
9
+ uncommittedCode, waitForRun, workflowPushProblem,
10
+ } from './github-ci.js';
11
+ import { renderCiWorkflow } from './workflow-scaffold.js';
12
+
13
+ export const CI_WAIT_MS = 50 * 60 * 1000;
14
+ const APPEAR_MS = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000);
15
+
16
+ export class CiUsageError extends Error {}
17
+
18
+ const NODE_KEYS = ['build', 'tests', 'validators', 'e2e'];
19
+
20
+ function isVerifyResult(result) {
21
+ return Boolean(result) && ['pass', 'fail'].includes(result.status) && result.scope === 'feature'
22
+ && NODE_KEYS.every((k) => typeof result.nodes?.[k]?.status === 'string');
23
+ }
24
+
25
+ function scaffoldWorkflow(root, config) {
26
+ const rel = `${CI_WORKFLOW_DIR}/${CI_WORKFLOW_FILE}`;
27
+ const abs = join(root, rel);
28
+ if (existsSync(abs)) return { error: `${rel} já existe e não chama morph-spec verify publicando o JSON` };
29
+ try {
30
+ mkdirSync(join(root, CI_WORKFLOW_DIR), { recursive: true });
31
+ writeFileSync(abs, renderCiWorkflow(root, config));
32
+ execFileSync('git', ['add', '--', rel], { cwd: root, stdio: 'pipe' });
33
+ execFileSync('git', ['commit', '-m', 'ci: workflow morph-verify (prova do Gate 3 no CI)', '--', rel], { cwd: root, stdio: 'pipe' });
34
+ return { path: rel };
35
+ } catch (err) {
36
+ return { error: `não consegui criar ${rel}: ${(err.stderr?.toString() || err.message).trim()}` };
37
+ }
38
+ }
39
+
40
+ function dispatchWorkflow(root, workflow, branch) {
41
+ try {
42
+ const { bin, prefix } = resolveGh();
43
+ execFileSync(bin, [...prefix, 'workflow', 'run', workflow, '--ref', branch], { cwd: root, stdio: 'pipe', timeout: 15000 });
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * `verify {f} --ci`: the feature-scope proof, run by the project's CI over the pushed commit.
52
+ * Every way CI cannot answer is `mode: 'local'` with the reason, and the caller runs the local
53
+ * verify: no CI is never a pause. Only the uploaded `verify --json` becomes a result, so the
54
+ * gate judges a CI run by the same stamp and the same rules as a local one.
55
+ *
56
+ * @returns {Promise<
57
+ * { mode: 'local', reason: string } |
58
+ * { mode: 'pending', reason: string, run: object|null } |
59
+ * { mode: 'ci', result: object, run: object, created: string|null }>}
60
+ * @throws {CiUsageError} code CI would not see (uncommitted work)
61
+ */
62
+ export async function runCiVerify(root, featureName, config, { log = () => {}, waitMs = CI_WAIT_MS, wait = waitForRun } = {}) {
63
+ const avail = ciAvailability(root, featureName, config);
64
+ if (!avail.ok) return { mode: 'local', reason: avail.reason };
65
+
66
+ const dirty = uncommittedCode(root);
67
+ if (dirty === null) return { mode: 'local', reason: 'git status não respondeu' };
68
+ if (dirty.length > 0) {
69
+ const shown = dirty.slice(0, 5).join(', ') + (dirty.length > 5 ? ` (+${dirty.length - 5})` : '');
70
+ throw new CiUsageError(`o CI só vê commits e há trabalho fora deles: ${shown}. Commite na branch da feature (git add <paths> && git commit) e rode de novo, ou rode sem --ci`);
71
+ }
72
+
73
+ let workflow = findCiWorkflow(root);
74
+ let created = null;
75
+ if (!workflow) {
76
+ const problem = workflowPushProblem(avail);
77
+ if (problem) return { mode: 'local', reason: problem };
78
+ const scaffold = scaffoldWorkflow(root, config);
79
+ if (scaffold.error) return { mode: 'local', reason: scaffold.error };
80
+ created = scaffold.path;
81
+ workflow = CI_WORKFLOW_FILE;
82
+ log(`workflow criado e commitado: ${created}`);
83
+ }
84
+
85
+ const pushError = pushBranch(root, avail.branch);
86
+ if (pushError) return { mode: 'local', reason: `push de ${avail.branch} falhou: ${pushError}` };
87
+
88
+ const head = getHeadSha(root);
89
+ const waitOpts = {
90
+ branch: avail.branch,
91
+ workflow,
92
+ isCurrent: (sha) => !treeMovedSince(root, sha, head),
93
+ timeoutMs: waitMs,
94
+ appearMs: APPEAR_MS,
95
+ onPending: (run) => log(`CI rodando: ${run.url}`),
96
+ };
97
+ let outcome = await wait(root, waitOpts);
98
+ if (outcome.state === 'no-run' && dispatchWorkflow(root, workflow, avail.branch)) {
99
+ log(`nenhum run para este commit; disparei ${workflow} manualmente`);
100
+ outcome = await wait(root, waitOpts);
101
+ }
102
+ if (outcome.state === 'no-run') return { mode: 'local', reason: `${workflow} não rodou para este commit` };
103
+ if (outcome.state === 'unknown') return { mode: 'local', reason: 'gh run list não respondeu' };
104
+ if (outcome.state === 'timeout') {
105
+ return { mode: 'pending', reason: `o CI não terminou em ${Math.round(waitMs / 60000)} min`, run: outcome.run ?? null };
106
+ }
107
+
108
+ const { run } = outcome;
109
+ const result = downloadResult(root, run.databaseId);
110
+ if (!isVerifyResult(result)) {
111
+ return {
112
+ mode: 'local',
113
+ reason: `run ${run.conclusion} sem o JSON do verify (${run.url}; gh run view ${run.databaseId} --log-failed)`,
114
+ };
115
+ }
116
+ const { persisted, reason, repeatOf, cleanup, ...verdict } = result;
117
+ return { mode: 'ci', result: verdict, run, created };
118
+ }
@@ -0,0 +1,192 @@
1
+ import { execFileSync, spawnSync } from 'child_process';
2
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import { resolveGh } from '../git/gh.js';
6
+
7
+ export const CI_WORKFLOW_DIR = '.github/workflows';
8
+ export const CI_WORKFLOW_FILE = 'morph-verify.yml';
9
+ export const CI_ARTIFACT = 'morph-verify';
10
+ export const CI_RESULT_FILE = 'morph-verify.json';
11
+
12
+ // A run that never judged the code (superseded, filtered out, quota) is no proof either way.
13
+ const NO_VERDICT = new Set(['cancelled', 'skipped', 'startup_failure', 'stale', 'neutral']);
14
+
15
+ const VERIFY_CALL = /morph-spec\S*\s+verify\b/;
16
+
17
+ const git = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
18
+
19
+ function gh(cwd, args, timeout = 15000) {
20
+ const { bin, prefix } = resolveGh();
21
+ return execFileSync(bin, [...prefix, ...args], { cwd, stdio: 'pipe', timeout }).toString();
22
+ }
23
+
24
+ /**
25
+ * The workflow that can stand in for `verify {feature}`: it must call `morph-spec verify` AND
26
+ * publish its JSON, because only that artifact becomes a stamp. A green run of any other
27
+ * workflow (lint, deploy) proves nothing about the suite.
28
+ *
29
+ * @param {string} root
30
+ * @returns {string|null} workflow file name, as `gh run list --workflow` takes it
31
+ */
32
+ export function findCiWorkflow(root) {
33
+ const dir = join(root, CI_WORKFLOW_DIR);
34
+ if (!existsSync(dir)) return null;
35
+ const files = readdirSync(dir).filter((f) => /\.ya?ml$/i.test(f)).sort((a, b) =>
36
+ (a === CI_WORKFLOW_FILE ? -1 : b === CI_WORKFLOW_FILE ? 1 : a.localeCompare(b)));
37
+ for (const file of files) {
38
+ let text;
39
+ try {
40
+ text = readFileSync(join(dir, file), 'utf8');
41
+ } catch {
42
+ continue;
43
+ }
44
+ if (VERIFY_CALL.test(text) && text.includes(CI_RESULT_FILE)) return file;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export function currentBranch(root) {
50
+ try {
51
+ return git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function originUrl(root) {
58
+ try {
59
+ return git(root, ['config', '--get', 'remote.origin.url']).trim();
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function ghAuthText(root) {
66
+ const { bin, prefix } = resolveGh();
67
+ const res = spawnSync(bin, [...prefix, 'auth', 'status'], { cwd: root, stdio: 'pipe', timeout: 10000 });
68
+ if (res.error || res.status !== 0) return null;
69
+ return `${res.stdout ?? ''}${res.stderr ?? ''}`;
70
+ }
71
+
72
+ /**
73
+ * Whether CI can answer for this feature, and why not. Never throws: every "no" is a reason the
74
+ * caller prints before falling back to the local verify.
75
+ *
76
+ * @param {string} root - the feature's tree
77
+ * @param {string} featureName
78
+ * @param {object|null} config - `.morph/config/config.json`
79
+ * @returns {{ ok: true, branch: string, https: boolean, scopes: string|null } | { ok: false, reason: string }}
80
+ */
81
+ export function ciAvailability(root, featureName, config) {
82
+ if (config?.project?.ci === false) return { ok: false, reason: 'project.ci = false no config.json' };
83
+ const url = originUrl(root);
84
+ if (!url) return { ok: false, reason: 'sem remote origin' };
85
+ if (!/github\.com/i.test(url)) return { ok: false, reason: 'origin não é GitHub' };
86
+ const branch = currentBranch(root);
87
+ if (branch !== `morph/${featureName}`) {
88
+ return { ok: false, reason: `branch atual "${branch}" não é morph/${featureName}` };
89
+ }
90
+ const auth = ghAuthText(root);
91
+ if (auth === null) return { ok: false, reason: 'gh ausente ou não autenticado' };
92
+ const scopes = auth.match(/Token scopes:\s*(.*)/i)?.[1] ?? null;
93
+ return { ok: true, branch, https: /^https?:/i.test(url), scopes };
94
+ }
95
+
96
+ /**
97
+ * Creating a workflow file over HTTPS needs the token's `workflow` scope; without it GitHub
98
+ * rejects the whole push. Unknown scopes (fine-grained token, SSH remote) are not a refusal.
99
+ */
100
+ export function workflowPushProblem({ https, scopes }) {
101
+ if (!https || scopes === null) return null;
102
+ return /\bworkflow\b/.test(scopes) ? null : 'o token do gh não tem o escopo "workflow" (gh auth refresh -s workflow)';
103
+ }
104
+
105
+ /**
106
+ * Paths CI would not see: uncommitted or untracked files outside `.morph/`, plus the config,
107
+ * which names the test commands.
108
+ *
109
+ * @returns {string[]|null} null when git did not answer
110
+ */
111
+ export function uncommittedCode(root) {
112
+ let out;
113
+ try {
114
+ out = git(root, ['status', '--porcelain', '-z', '--untracked-files=all', '--no-renames']);
115
+ } catch {
116
+ return null;
117
+ }
118
+ return out.split('\0').filter(Boolean).map((line) => line.slice(3))
119
+ .filter((path) => !path.startsWith('.morph/') || path === '.morph/config/config.json');
120
+ }
121
+
122
+ export function pushBranch(root, branch) {
123
+ try {
124
+ git(root, ['push', '-u', 'origin', branch]);
125
+ return null;
126
+ } catch (err) {
127
+ return (err.stderr?.toString() || err.message).trim().split('\n').slice(-3).join(' ');
128
+ }
129
+ }
130
+
131
+ /** @returns {Array<object>|null} newest first; null = gh did not answer */
132
+ export function listRuns(root, branch, workflow) {
133
+ try {
134
+ return JSON.parse(gh(root, ['run', 'list', '--branch', branch, '--workflow', workflow, '--limit', '20',
135
+ '--json', 'databaseId,status,conclusion,headSha,url,createdAt']));
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The newest run over the same code as HEAD. `isCurrent` is a tree comparison, not SHA equality:
143
+ * the evaluator and the archive commit only `.morph/`, and the workflow does not re-run for them.
144
+ */
145
+ export function pickRun(runs, isCurrent) {
146
+ return (runs || []).find((run) => !(run.status === 'completed' && NO_VERDICT.has(run.conclusion))
147
+ && run.headSha && isCurrent(run.headSha)) ?? null;
148
+ }
149
+
150
+ /** @returns {object|null} the parsed `verify --json` the run uploaded; null when absent or unreadable */
151
+ export function downloadResult(root, runId) {
152
+ const dir = mkdtempSync(join(tmpdir(), 'morph-ci-'));
153
+ try {
154
+ gh(root, ['run', 'download', String(runId), '--name', CI_ARTIFACT, '--dir', dir], 120000);
155
+ return JSON.parse(readFileSync(join(dir, CI_RESULT_FILE), 'utf8'));
156
+ } catch {
157
+ return null;
158
+ } finally {
159
+ rmSync(dir, { recursive: true, force: true });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Polls until the run over the current tree completes. Never decides: the caller turns
165
+ * `timeout`/`no-run`/`unknown` into "no CI proof", never into red.
166
+ *
167
+ * @returns {Promise<{ state: 'completed'|'timeout'|'no-run'|'unknown', run?: object }>}
168
+ */
169
+ export async function waitForRun(root, { branch, workflow, isCurrent, timeoutMs, appearMs, pollMs = 15000,
170
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onPending = () => {}, list = listRuns }) {
171
+ const start = now();
172
+ let seen = false;
173
+ let misses = 0;
174
+ for (;;) {
175
+ const runs = list(root, branch, workflow);
176
+ if (runs === null) {
177
+ if (++misses >= 3) return { state: 'unknown' };
178
+ } else {
179
+ misses = 0;
180
+ const run = pickRun(runs, isCurrent);
181
+ if (run?.status === 'completed') return { state: 'completed', run };
182
+ if (run) {
183
+ if (!seen) onPending(run);
184
+ seen = true;
185
+ }
186
+ }
187
+ const elapsed = now() - start;
188
+ if (!seen && elapsed >= appearMs) return { state: 'no-run' };
189
+ if (elapsed >= timeoutMs) return { state: 'timeout' };
190
+ await sleep(pollMs);
191
+ }
192
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, readdirSync } from 'fs';
2
+ import { join, posix, relative, resolve } from 'path';
3
+ import { PACKAGE_LOCATIONS } from '../detectors/index.js';
4
+ import { CI_ARTIFACT, CI_RESULT_FILE } from './github-ci.js';
5
+
6
+ const LOCKFILES = [
7
+ ['package-lock.json', 'npm ci'],
8
+ ['pnpm-lock.yaml', 'corepack enable && pnpm install --frozen-lockfile'],
9
+ ['yarn.lock', 'corepack enable && yarn install --frozen-lockfile'],
10
+ ];
11
+ const PLAYWRIGHT_CONFIGS = ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs'];
12
+
13
+ function packageDirs(root, config) {
14
+ const dirs = [...PACKAGE_LOCATIONS];
15
+ if (typeof config?.project?.frontendPath === 'string') dirs.push(config.project.frontendPath);
16
+ for (const node of Array.isArray(config?.project?.tests) ? config.project.tests : []) {
17
+ if (typeof node?.cwd === 'string') dirs.push(node.cwd);
18
+ }
19
+ const seen = new Set();
20
+ const out = [];
21
+ for (const dir of dirs) {
22
+ const abs = resolve(root, dir.trim() || '.');
23
+ const rel = relative(root, abs).split('\\').join('/') || '.';
24
+ if (rel.startsWith('..') || seen.has(rel)) continue;
25
+ seen.add(rel);
26
+ const lock = LOCKFILES.find(([file]) => existsSync(join(abs, file)));
27
+ if (lock) out.push({ dir: rel, install: lock[1], playwright: PLAYWRIGHT_CONFIGS.some((f) => existsSync(join(abs, f))) });
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function usesDotnet(root, config) {
33
+ if (existsSync(join(root, 'global.json'))) return true;
34
+ if (/dotnet/i.test(String(config?.project?.stack ?? ''))) return true;
35
+ if ((config?.project?.tests ?? []).some?.((node) => node?.runner === 'dotnet' || /\bdotnet\b/.test(node?.command ?? ''))) return true;
36
+ try {
37
+ return readdirSync(root).some((f) => /\.(sln|slnx|csproj)$/i.test(f));
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function dotnetSetup(root, config) {
44
+ if (!usesDotnet(root, config)) return [];
45
+ const hasGlobalJson = existsSync(join(root, 'global.json'));
46
+ return [
47
+ ' - uses: actions/setup-dotnet@v4',
48
+ ' with:',
49
+ hasGlobalJson ? ' global-json-file: global.json' : " dotnet-version: '10.0.x'",
50
+ ];
51
+ }
52
+
53
+ const step = (name, dir, run) => [
54
+ ` - name: ${name}`,
55
+ ...(dir === '.' ? [] : [` working-directory: ${posix.normalize(dir)}`]),
56
+ ` run: ${run}`,
57
+ ];
58
+
59
+ /**
60
+ * The workflow `verify {f} --ci` relies on. Same judge as the local run: it calls the project's
61
+ * pinned morph-spec, so the suite is whatever `project.tests` says, not a second definition here.
62
+ * `.morph/` is out of the trigger (bookkeeping commits would cancel the run over the code) except
63
+ * the config, which names the commands.
64
+ *
65
+ * @param {string} root
66
+ * @param {object|null} config
67
+ * @returns {string}
68
+ */
69
+ export function renderCiWorkflow(root, config) {
70
+ const packages = packageDirs(root, config);
71
+ const lines = [
72
+ '# Gerado por morph-spec: a prova do Gate 3 roda aqui (verify {feature} --ci).',
73
+ 'name: morph-verify',
74
+ '',
75
+ 'on:',
76
+ ' push:',
77
+ " branches: ['morph/**']",
78
+ ' paths:',
79
+ " - '**'",
80
+ " - '!.morph/**'",
81
+ " - '.morph/config/config.json'",
82
+ ' workflow_dispatch:',
83
+ '',
84
+ 'concurrency:',
85
+ ' group: morph-verify-${{ github.ref }}',
86
+ ' cancel-in-progress: true',
87
+ '',
88
+ 'jobs:',
89
+ ' verify:',
90
+ ' runs-on: ubuntu-latest',
91
+ ' timeout-minutes: 45',
92
+ ' env:',
93
+ " MORPH_HEAVY: 'off'",
94
+ ' steps:',
95
+ ' - uses: actions/checkout@v4',
96
+ ' - uses: actions/setup-node@v4',
97
+ ' with:',
98
+ " node-version: '22'",
99
+ ...dotnetSetup(root, config),
100
+ ];
101
+ for (const pkg of packages) {
102
+ lines.push(...step(`install (${pkg.dir})`, pkg.dir, pkg.install));
103
+ if (pkg.playwright) lines.push(...step(`playwright browsers (${pkg.dir})`, pkg.dir, 'npx playwright install --with-deps chromium'));
104
+ }
105
+ lines.push(
106
+ ' - name: morph-spec verify',
107
+ ' shell: bash',
108
+ ' run: |',
109
+ ' feature="${GITHUB_REF_NAME#morph/}"',
110
+ " version=$(node -p \"try { require('./.morph/.morphversion').morphVersion } catch { 'latest' }\")",
111
+ ` npx -y "@polymorphism-tech/morph-spec@\${version}" verify "$feature" --json --no-persist > ${CI_RESULT_FILE}`,
112
+ ' - uses: actions/upload-artifact@v4',
113
+ ' if: always()',
114
+ ' with:',
115
+ ` name: ${CI_ARTIFACT}`,
116
+ ` path: ${CI_RESULT_FILE}`,
117
+ ' if-no-files-found: ignore',
118
+ ' retention-days: 7',
119
+ '',
120
+ );
121
+ return lines.join('\n');
122
+ }
@@ -79,6 +79,19 @@ export function evaluatorReportState(cwd, featureName) {
79
79
  return { status: 'ok', path, ...parsed };
80
80
  }
81
81
 
82
+ /** O sinal `evaluatorVerdict` que `verdictPause` (risk-detector) lê — gate-check e approve leem o mesmo. */
83
+ export function verdictSignal(report) {
84
+ if (report.status !== 'ok') return { status: report.status };
85
+ return {
86
+ status: 'ok',
87
+ verdict: report.verdict,
88
+ blockers: report.blockers.length,
89
+ blockerIds: report.blockers.map((b) => b.id),
90
+ untyped: report.untyped.length,
91
+ superseded: report.superseded,
92
+ };
93
+ }
94
+
82
95
  export function evaluatorMediaSection(cwd, featureName) {
83
96
  const text = readReport(cwd, featureName);
84
97
  return text !== null && MEDIA_SECTION_RE.test(text);
@@ -244,6 +244,14 @@ export function shouldPauseGate(config, signals) {
244
244
  };
245
245
  }
246
246
 
247
+ // No ship, ausência de stamp não é "verde": uma feature foi aprovada em campo sem verify nenhum.
248
+ if (signals.gate === 'review' && !signals.verificationStatus) {
249
+ return {
250
+ pause: true,
251
+ reason: 'nenhum `morph-spec verify` carimbado nesta feature — rode `morph-spec verify {feature}` antes do ship',
252
+ };
253
+ }
254
+
247
255
  // No gate de SHIP: um `verification` PRESENTE cujo escopo não é o da feature
248
256
  // é um recorte parcial — de UMA task, nunca da app inteira. O stamp é
249
257
  // SINGULAR, sobrescrito por qualquer verify de qualquer escopo, e vale
@@ -19,7 +19,7 @@ const NO_VALIDATOR_STACKS = new Set(['nodejs-cli', 'node-cli', 'cli']);
19
19
  const PROFILES = {
20
20
  dotnet: {
21
21
  isDotnet: true, isNextjs: false,
22
- defaultValidators: ['architecture', 'packages'],
22
+ defaultValidators: ['packages'],
23
23
  buildCommand: 'dotnet build --no-restore',
24
24
  },
25
25
  nextjs: {
@@ -14,10 +14,10 @@ const NODE_NAMES = ['build', 'tests', 'validators', 'e2e'];
14
14
  /**
15
15
  * @param {Object} result - runVerify() output
16
16
  * @param {{ sha: string|null, tree: 'root'|'worktree', persisted: boolean, reason: string|null,
17
- * fingerprint?: string|null }} context
17
+ * fingerprint?: string|null, source?: 'local'|'ci' }} context
18
18
  * @returns {Object}
19
19
  */
20
- export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerprint = null }) {
20
+ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerprint = null, source = 'local' }) {
21
21
  const nodes = {};
22
22
  for (const name of NODE_NAMES) {
23
23
  const node = result.nodes?.[name];
@@ -40,6 +40,7 @@ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerp
40
40
  tree,
41
41
  persisted,
42
42
  reason: persisted ? null : (reason ?? null),
43
+ source,
43
44
  nodes,
44
45
  };
45
46
  }
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Reuse Validator — símbolo novo cujo nome já existe no codebase.
3
3
  *
4
- * A terceira camada do ciclo de reuso, no molde do `design-system-validator`:
4
+ * A terceira camada do ciclo de reuso:
5
5
  *
6
6
  * PROSA (morph-plan, personas) ──► ARTEFATO (`reuse` em tasks.json)
7
7
  * ──► COBRANÇA (este arquivo)
8
8
  *
9
9
  * Prosa sozinha não segura — `morph-brainstorming/SKILL.md:39` já manda olhar
10
10
  * o que existe, e o ProspectPRO redefiniu `SimpleBuilding` em cinco arquivos da
11
- * MESMA pasta assim mesmo. O único ciclo que funciona no framework (design
12
- * tokens) tem verificação em código; este é o equivalente para código.
11
+ * MESMA pasta assim mesmo: só a verificação em código segura o ciclo.
13
12
  *
14
13
  * Regras, na ordem em que reduzem ruído:
15
14
  *
@@ -9,8 +9,7 @@
9
9
  * papéis produziria um validador cego justamente na janela em que ele precisa
10
10
  * morder — ver graph-loader.js `getFreshness`.
11
11
  *
12
- * A varredura é por linha, com regex ancorada no início da declaração. Mesma
13
- * limitação (e mesmo grau de tolerância) do design-system-validator: uma
12
+ * A varredura é por linha, com regex ancorada no início da declaração: uma
14
13
  * declaração citada dentro de uma string ou de um comentário de bloco pode
15
14
  * passar. O custo de errar é um aviso a mais, nunca um bloqueio silencioso.
16
15
  *
@@ -28,8 +27,7 @@ import { isProjectSource } from '../../../../framework/hooks/shared/source-files
28
27
  *
29
28
  * A lista é curta de propósito e só admite nome genérico de UMA palavra: assim
30
29
  * que há qualificador de domínio (`BuildingFiltersProps`, `FilterOption`) o
31
- * nome volta a ser um conceito e a regra vale. Mesmo papel da allowlist de
32
- * cores de sistema no design-system-validator.
30
+ * nome volta a ser um conceito e a regra vale.
33
31
  */
34
32
  const IDIOMATIC_LOCAL_NAMES = new Set([
35
33
  'props', 'state', 'row', 'item', 'column', 'context', 'params', 'options',