@polymorphism-tech/morph-spec 8.47.0 → 9.0.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 +48 -39
- package/README.md +3 -4
- package/bin/morph-spec.js +37 -51
- package/bin/validate.js +3 -111
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +41 -101
- package/framework/MORPH.md +95 -252
- package/framework/agents.json +75 -18
- package/framework/commands/morph-apply.md +5 -28
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-hotfix.md +8 -8
- package/framework/commands/morph-infra.md +1 -1
- package/framework/commands/morph-preflight.md +2 -3
- package/framework/commands/morph-proposal.md +2 -50
- package/framework/commands/morph-status.md +3 -3
- package/framework/docs/CLI.md +585 -286
- package/framework/evals/review-checklist.md +82 -41
- package/framework/hooks/README.md +10 -11
- package/framework/hooks/claude-code/core/comment-guard.js +1 -1
- package/framework/hooks/claude-code/core/denylist.json +33 -3
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
- package/framework/hooks/claude-code/core/gate-guard.js +2 -11
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
- package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
- package/framework/hooks/claude-code/core/state-sync.js +7 -21
- package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
- package/framework/hooks/claude-code/core/validate-completion.js +33 -97
- package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
- package/framework/hooks/claude-code/core/worktree-context.js +5 -4
- package/framework/hooks/claude-code/statusline.py +55 -41
- package/framework/hooks/package.json +3 -0
- package/framework/hooks/shared/activity-logger.js +1 -1
- package/framework/hooks/shared/build-check.js +10 -6
- package/framework/hooks/shared/command-tokens.js +169 -0
- package/framework/hooks/shared/comment-rules.js +1 -1
- package/framework/hooks/shared/comment-scanner.js +10 -23
- package/framework/hooks/shared/comment-stripper.js +1 -1
- package/framework/hooks/shared/e2e-check.js +64 -27
- package/framework/hooks/shared/errata.js +72 -0
- package/framework/hooks/shared/feature-locator.js +1 -1
- package/framework/hooks/shared/frontend-locations.js +6 -0
- package/framework/hooks/shared/gate-question.js +18 -0
- package/framework/hooks/shared/phase-utils.js +15 -32
- package/framework/hooks/shared/slice-progress.js +118 -0
- package/framework/hooks/shared/spawn-command.js +23 -0
- package/framework/hooks/shared/state-reader.js +61 -87
- package/framework/hooks/shared/state-writer.js +2 -3
- package/framework/mcp/registry.json +3 -4
- package/framework/phases.json +23 -39
- package/framework/schemas/config.schema.json +14 -9
- package/framework/schemas/feature.schema.json +15 -50
- package/framework/skills/README.md +3 -4
- 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 +149 -222
- package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
- 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 +137 -299
- package/framework/skills/core/morph-review/SKILL.md +111 -136
- package/framework/skills/core/morph-standards/SKILL.md +27 -39
- package/framework/skills/core/morph-uiux/SKILL.md +77 -152
- package/framework/standards/STANDARDS.json +86 -1218
- package/framework/standards/STANDARDS.meta.json +1305 -0
- package/framework/standards/ai-agents/evals-with-cache.md +1 -2
- package/framework/standards/ai-agents/setup.md +1 -1
- package/framework/standards/architecture/code-comments.md +6 -6
- package/framework/standards/integration/mcp/mcp-tools.md +7 -7
- package/framework/templates/README.md +2 -3
- package/framework/templates/REGISTRY.json +6 -170
- package/framework/templates/docs/plan.md +72 -0
- package/framework/templates/dotnet/ai-kit/README.md +1 -9
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
- package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
- package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
- package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
- package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +2 -2
- package/src/commands/observability/cost.js +18 -60
- package/src/commands/project/doctor-procs.js +1 -1
- package/src/commands/project/doctor.js +9 -9
- package/src/commands/project/fleet.js +4 -4
- package/src/commands/project/heavy.js +3 -3
- package/src/commands/project/init.js +4 -0
- package/src/commands/project/setup-infra-cmd.js +2 -1
- package/src/commands/project/stack-scan.js +58 -0
- package/src/commands/project/standards.js +79 -25
- package/src/commands/project/status.js +29 -69
- package/src/commands/project/update.js +17 -1
- package/src/commands/project/worktree.js +1 -1
- package/src/commands/state/advance.js +62 -46
- package/src/commands/state/approve.js +81 -141
- package/src/commands/state/archive.js +20 -84
- package/src/commands/state/auto-approval.js +68 -0
- package/src/commands/state/create.js +28 -1
- package/src/commands/state/delete.js +1 -1
- package/src/commands/state/finish.js +11 -23
- package/src/commands/state/gate-check.js +85 -84
- package/src/commands/state/gate-decision.js +6 -4
- package/src/commands/state/handoff.js +1 -1
- package/src/commands/state/list.js +9 -9
- package/src/commands/state/report-check.js +157 -311
- package/src/commands/state/retype.js +9 -3
- package/src/commands/validation/mutate.js +77 -14
- package/src/commands/validation/plan-check.js +81 -0
- package/src/commands/validation/validate-feature.js +60 -135
- package/src/commands/validation/verify.js +40 -72
- package/src/core/paths/output-schema.js +19 -66
- package/src/core/state/gate-order.js +5 -6
- package/src/core/state/lifecycle-audit.js +2 -2
- package/src/core/state/state-manager.js +17 -98
- package/src/core/templates/template-registry.js +2 -2
- package/src/core/templates/template-seed.js +22 -0
- package/src/core/work-types.js +10 -10
- package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
- package/src/dashboard/app/components/CommandPalette.jsx +5 -5
- package/src/dashboard/app/components/EntityLink.jsx +1 -1
- package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
- package/src/dashboard/app/lib/phases.js +2 -4
- package/src/dashboard/app/pages/Activity.jsx +1 -1
- package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
- package/src/dashboard/app/pages/Features.jsx +9 -9
- package/src/dashboard/app/pages/Guide.jsx +2 -2
- package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
- package/src/dashboard/app/pages/Standards.jsx +1 -1
- package/src/dashboard/scanner/activity-scanner.js +5 -15
- package/src/dashboard/scanner/agents-scanner.js +1 -1
- package/src/dashboard/scanner/docs-scanner.js +2 -2
- package/src/dashboard/scanner/features-scanner.js +95 -60
- package/src/dashboard/scanner/project-api.js +1 -1
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ci/ci-verify.js +17 -1
- package/src/lib/ci/github-ci.js +1 -1
- package/src/lib/detectors/index.js +3 -8
- package/src/lib/generators/handoff-generator.js +34 -65
- package/src/lib/generators/settings-generator.js +11 -89
- package/src/lib/git/evidence-staleness.js +1 -1
- package/src/lib/git/tree-fingerprint.js +19 -7
- package/src/lib/git/tree-state.js +2 -2
- package/src/lib/heavy/cleanup.js +1 -5
- package/src/lib/installers/memory-installer.js +6 -8
- package/src/lib/mutation/commands.js +3 -2
- package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
- package/src/lib/mutation/plan.js +14 -6
- package/src/lib/mutation/runner.js +1 -1
- package/src/lib/mutation/subset.js +46 -9
- package/src/lib/plan/artifact-vocabulary.js +60 -0
- package/src/lib/plan/checks.js +234 -0
- package/src/lib/plan/plan-parser.js +209 -0
- package/src/lib/plan/plan-template.js +18 -0
- package/src/lib/review-reports.js +222 -0
- package/src/lib/risk-detector.js +131 -140
- package/src/lib/specs/adr-index.js +50 -69
- package/src/lib/specs/design-seed.js +18 -0
- package/src/lib/specs/frontmatter.js +44 -0
- package/src/lib/specs/gfm-table.js +17 -0
- package/src/lib/specs/old-format.js +68 -0
- package/src/lib/specs/revocations.js +37 -95
- package/src/lib/specs/screen-checklist.js +18 -17
- package/src/lib/stack/dotnet-build-ignores.js +47 -0
- package/src/lib/stack/stack-scan.js +267 -0
- package/src/lib/standards/registry.js +43 -0
- package/src/lib/standards/sections.js +58 -0
- package/src/lib/tasks/eval-node.js +4 -12
- package/src/lib/tasks/test-nodes.js +1 -74
- package/src/lib/tasks/test-runner.js +75 -117
- package/src/lib/telemetry/metrics.js +8 -69
- package/src/lib/telemetry/telemetry-reader.js +5 -118
- package/src/lib/telemetry/verify-event.js +5 -7
- package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
- package/src/lib/validators/reuse/reuse-validator.js +22 -45
- package/src/lib/validators/traceability-check.js +85 -129
- package/src/lib/validators/verify-runner.js +118 -235
- package/src/lib/worktree/docker-audit.js +1 -2
- package/src/lib/worktree/fleet-scanner.js +25 -38
- package/src/scripts/setup-infra.js +9 -0
- package/src/utils/agents-installer.js +76 -9
- package/src/utils/claude-settings-manager.js +0 -35
- package/src/utils/file-copier.js +27 -1
- package/src/utils/hooks-installer.js +16 -9
- package/src/utils/skills-installer.js +1 -2
- package/framework/evals/trace-schema.md +0 -67
- package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
- package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
- package/framework/hooks/git/pre-commit/specs.sh +0 -50
- package/framework/hooks/shared/tasks-validator.js +0 -111
- package/framework/rules/morph-task-tracking.md +0 -54
- package/framework/rules/morph-workflow.md +0 -244
- package/framework/schemas/tasks.schema.json +0 -79
- package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
- package/framework/skills/core/morph-implement/SKILL.md +0 -194
- package/framework/skills/core/morph-proposal/SKILL.md +0 -152
- package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
- package/framework/templates/docs/proposal.md +0 -192
- package/framework/templates/docs/spec.md +0 -57
- package/framework/templates/docs/ui-components.md +0 -52
- package/framework/templates/docs/ui-design-system.md +0 -76
- package/framework/templates/docs/ui-flows.md +0 -167
- package/framework/templates/examples/spec-examples.md +0 -91
- package/framework/templates/feature/decisions.md +0 -124
- package/framework/templates/feature/recap.md +0 -145
- package/framework/templates/tasks.template.json +0 -17
- package/src/commands/state/usecase.js +0 -143
- package/src/commands/tasks/dag.js +0 -181
- package/src/lib/agents/output-collector.js +0 -76
- package/src/lib/evaluator-report.js +0 -104
- package/src/lib/specs/acervo-format.js +0 -70
- package/src/lib/specs/acervo-lifecycle.js +0 -264
- package/src/lib/specs/done-criteria-check.js +0 -200
- package/src/lib/specs/usecase-check.js +0 -301
- package/src/lib/specs/usecase-promoter.js +0 -301
- package/src/lib/specs/usecase-registry.js +0 -237
- package/src/lib/tasks/dispatch-planner.js +0 -319
- package/src/lib/tasks/mermaid-renderer.js +0 -132
- package/src/lib/tasks/task-classifier.js +0 -30
- package/src/lib/tasks/task-parser.js +0 -209
- package/src/lib/tasks/tasks-json-parser.js +0 -296
- package/src/lib/tasks/test-scope.js +0 -597
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
|
|
23
23
|
import { execFileSync } from 'child_process';
|
|
24
24
|
import { createHash } from 'crypto';
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
25
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
26
26
|
import { join } from 'path';
|
|
27
27
|
|
|
28
|
+
import { E2E_SPEC_LOCATIONS } from './frontend-locations.js';
|
|
28
29
|
import { isSecondaryWorktree } from './git-paths.js';
|
|
30
|
+
import { resolveSpawn } from './spawn-command.js';
|
|
29
31
|
|
|
30
32
|
/** Compose filenames Docker itself recognises, in the order it resolves them. */
|
|
31
33
|
const COMPOSE_CANDIDATES = [
|
|
@@ -42,9 +44,6 @@ const PLAYWRIGHT_CONFIGS = [
|
|
|
42
44
|
'playwright.config.mjs',
|
|
43
45
|
];
|
|
44
46
|
|
|
45
|
-
/** Task groups that touch runtime and therefore deserve an e2e pass. */
|
|
46
|
-
const RUNTIME_GROUPS = ['frontend', 'backend', 'infra'];
|
|
47
|
-
|
|
48
47
|
/** How long to wait for every service to report healthy. */
|
|
49
48
|
export const DEFAULT_HEALTH_TIMEOUT = 180_000;
|
|
50
49
|
|
|
@@ -71,9 +70,11 @@ const OUTPUT_TAIL_LINES = 20;
|
|
|
71
70
|
* @returns {{ code: number, output: string }}
|
|
72
71
|
*/
|
|
73
72
|
export function defaultExec(file, args, opts = {}) {
|
|
73
|
+
const spawn = resolveSpawn(file, args, { cwd: opts.cwd });
|
|
74
74
|
try {
|
|
75
|
-
const stdout = execFileSync(file, args, {
|
|
75
|
+
const stdout = execFileSync(spawn.file, spawn.args, {
|
|
76
76
|
cwd: opts.cwd,
|
|
77
|
+
shell: spawn.shell,
|
|
77
78
|
timeout: opts.timeout,
|
|
78
79
|
encoding: 'utf-8',
|
|
79
80
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -125,8 +126,7 @@ export function resolveSpecCommand(cwd, config, frontendPath) {
|
|
|
125
126
|
return { command, args, cwd };
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
const
|
|
129
|
-
for (const dir of searchDirs) {
|
|
129
|
+
for (const dir of specSearchDirs(cwd, frontendPath)) {
|
|
130
130
|
const hasConfig = PLAYWRIGHT_CONFIGS.some((name) => existsSync(join(dir, name)));
|
|
131
131
|
// node_modules guard, same rationale as getBuildSteps(): a config file is
|
|
132
132
|
// versioned but the toolchain is not, and `npx playwright` in a fresh
|
|
@@ -138,6 +138,49 @@ export function resolveSpecCommand(cwd, config, frontendPath) {
|
|
|
138
138
|
return null;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
function specSearchDirs(cwd, frontendPath) {
|
|
142
|
+
return frontendPath ? [join(cwd, frontendPath), cwd] : E2E_SPEC_LOCATIONS.map((loc) => join(cwd, loc));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasPlaywrightConfig(dir) {
|
|
146
|
+
return PLAYWRIGHT_CONFIGS.some((name) => existsSync(join(dir, name)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Configs `resolveSpecCommand` did not run (searched dir without node_modules,
|
|
150
|
+
// or a first-level dir outside the search): each is a smoke pass hiding specs.
|
|
151
|
+
export function findUnrunPlaywrightConfigs(cwd, frontendPath) {
|
|
152
|
+
const searched = frontendPath ? [frontendPath, '.'] : [...E2E_SPEC_LOCATIONS];
|
|
153
|
+
const norm = (rel) => rel.replace(/\\/g, '/').replace(/^\.\/|\/$/g, '') || '.';
|
|
154
|
+
const searchedSet = new Set(searched.map(norm));
|
|
155
|
+
let firstLevel = [];
|
|
156
|
+
try {
|
|
157
|
+
firstLevel = readdirSync(cwd, { withFileTypes: true })
|
|
158
|
+
.filter((e) => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
|
|
159
|
+
.map((e) => e.name);
|
|
160
|
+
} catch { /* unreadable root: nothing to report */ }
|
|
161
|
+
|
|
162
|
+
const found = [];
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
for (const rel of [...searched.map(norm), ...firstLevel]) {
|
|
165
|
+
if (seen.has(rel)) continue;
|
|
166
|
+
seen.add(rel);
|
|
167
|
+
const dir = join(cwd, rel);
|
|
168
|
+
if (!hasPlaywrightConfig(dir)) continue;
|
|
169
|
+
if (!searchedSet.has(rel)) found.push({ dir: rel, why: 'not-searched' });
|
|
170
|
+
else if (!existsSync(join(dir, 'node_modules'))) found.push({ dir: rel, why: 'no-node-modules' });
|
|
171
|
+
}
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function unrunConfigWarning({ dir, why }) {
|
|
176
|
+
const where = dir === '.' ? 'na raiz' : `em ${dir}/`;
|
|
177
|
+
const cause = why === 'no-node-modules'
|
|
178
|
+
? 'sem node_modules ao lado'
|
|
179
|
+
: 'fora de project.frontendPath e dos locais conhecidos';
|
|
180
|
+
const fix = dir === '.' ? 'instale as dependências' : `declare project.frontendPath: "${dir}" e instale as dependências`;
|
|
181
|
+
return `playwright.config.* ${where} não rodou (${cause}) — o nó e2e ficou só no smoke; ${fix}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
141
184
|
/**
|
|
142
185
|
* Compose project name for a feature — the isolation key that lets two
|
|
143
186
|
* worktrees run their stacks side by side without fighting over container
|
|
@@ -486,7 +529,7 @@ export function isDockerAvailable(cwd, exec = defaultExec) {
|
|
|
486
529
|
// ============================================================================
|
|
487
530
|
|
|
488
531
|
/**
|
|
489
|
-
* Decide whether this
|
|
532
|
+
* Decide whether this project deserves an e2e run, and if not, why.
|
|
490
533
|
*
|
|
491
534
|
* The load-bearing rule is the third one: a project that declares Docker in
|
|
492
535
|
* `project.integrations` but has no compose file is NOT dispensed — it is a
|
|
@@ -494,13 +537,10 @@ export function isDockerAvailable(cwd, exec = defaultExec) {
|
|
|
494
537
|
* would end up decorative (every project without a compose file collecting a
|
|
495
538
|
* green Gate 3 forever).
|
|
496
539
|
*
|
|
497
|
-
* @param {{
|
|
498
|
-
* integrations?: string[], skipRequested?: boolean }} params
|
|
540
|
+
* @param {{ composeFile: string|null, integrations?: string[], skipRequested?: boolean }} params
|
|
499
541
|
* @returns {{ run: boolean, status?: 'skip'|'fail', reason?: string }}
|
|
500
542
|
*/
|
|
501
543
|
export function decideApplicability({
|
|
502
|
-
taskGroup,
|
|
503
|
-
scope = 'feature',
|
|
504
544
|
composeFile,
|
|
505
545
|
integrations = [],
|
|
506
546
|
skipRequested = false,
|
|
@@ -509,13 +549,6 @@ export function decideApplicability({
|
|
|
509
549
|
return { run: false, status: 'skip', reason: 'requested' };
|
|
510
550
|
}
|
|
511
551
|
|
|
512
|
-
// Task scope only runs for groups that touch runtime; feature scope (Gate 3)
|
|
513
|
-
// always runs. An absent group is treated as runtime-touching — the safe
|
|
514
|
-
// direction, since a missing field must never buy a free pass.
|
|
515
|
-
if (scope === 'task' && taskGroup && !RUNTIME_GROUPS.includes(String(taskGroup).toLowerCase())) {
|
|
516
|
-
return { run: false, status: 'skip', reason: `task group "${taskGroup}" does not touch runtime` };
|
|
517
|
-
}
|
|
518
|
-
|
|
519
552
|
if (!composeFile) {
|
|
520
553
|
if (integrations.map((i) => String(i).toLowerCase()).includes('docker')) {
|
|
521
554
|
return {
|
|
@@ -836,8 +869,6 @@ export function prepareAuth(
|
|
|
836
869
|
* @param {object} [options]
|
|
837
870
|
* @param {object} [options.config] - .morph/config/config.json contents
|
|
838
871
|
* @param {string} [options.featureName] - drives COMPOSE_PROJECT_NAME
|
|
839
|
-
* @param {string|null} [options.taskGroup] - task's `group`, for applicability
|
|
840
|
-
* @param {string} [options.scope] - 'feature' | 'task'
|
|
841
872
|
* @param {string} [options.gitCommonDir] - where the freshness stamp lives
|
|
842
873
|
* @param {object} [options.env] - extra env (port block) for compose
|
|
843
874
|
* @param {boolean} [options.skipRequested]
|
|
@@ -852,8 +883,6 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
852
883
|
const {
|
|
853
884
|
config,
|
|
854
885
|
featureName = 'default',
|
|
855
|
-
taskGroup = null,
|
|
856
|
-
scope = 'feature',
|
|
857
886
|
gitCommonDir = null,
|
|
858
887
|
env,
|
|
859
888
|
skipRequested = false,
|
|
@@ -863,6 +892,7 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
863
892
|
healthTimeout = DEFAULT_HEALTH_TIMEOUT,
|
|
864
893
|
specTimeout = DEFAULT_SPEC_TIMEOUT,
|
|
865
894
|
beforeHeavy = null,
|
|
895
|
+
countSpecs = null,
|
|
866
896
|
_exec: exec = defaultExec,
|
|
867
897
|
} = options;
|
|
868
898
|
|
|
@@ -870,7 +900,7 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
870
900
|
const composeFile = resolveComposeFile(cwd, config);
|
|
871
901
|
const integrations = Array.isArray(config?.project?.integrations) ? config.project.integrations : [];
|
|
872
902
|
|
|
873
|
-
const applicability = decideApplicability({
|
|
903
|
+
const applicability = decideApplicability({ composeFile, integrations, skipRequested });
|
|
874
904
|
if (!applicability.run) {
|
|
875
905
|
return { status: applicability.status, command: null, output: '', reason: applicability.reason, warnings };
|
|
876
906
|
}
|
|
@@ -936,7 +966,8 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
936
966
|
if (auth.status === 'fail') {
|
|
937
967
|
return { status: 'fail', command: null, output: '', reason: auth.reason, warnings };
|
|
938
968
|
}
|
|
939
|
-
|
|
969
|
+
const specs = runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv, countSpecs }, exec);
|
|
970
|
+
return { ...specs, warnings: [...warnings, ...(specs.warnings || [])] };
|
|
940
971
|
} finally {
|
|
941
972
|
if (!keepUp) {
|
|
942
973
|
tearDownStack(cwd, { composeFile, projectName, env: composeEnv }, exec);
|
|
@@ -952,8 +983,9 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
952
983
|
* hardcoded localhost:3000 — ECONNREFUSED that looks exactly like a broken app.
|
|
953
984
|
* `PLAYWRIGHT_BASE_URL` travels in that env for the config to read.
|
|
954
985
|
*/
|
|
955
|
-
function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv }, exec) {
|
|
956
|
-
const
|
|
986
|
+
function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv, countSpecs }, exec) {
|
|
987
|
+
const frontendPath = config?.project?.frontendPath;
|
|
988
|
+
const spec = resolveSpecCommand(cwd, config, frontendPath);
|
|
957
989
|
const notes = [up.reused ? up.reason : null, auth?.reason].filter(Boolean);
|
|
958
990
|
|
|
959
991
|
if (!spec) {
|
|
@@ -962,11 +994,14 @@ function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv
|
|
|
962
994
|
command: `docker compose -f ${composeFile} up -d --wait`,
|
|
963
995
|
output: '',
|
|
964
996
|
reason: joinNotes('smoke only — stack healthy, no Playwright specs found', notes),
|
|
997
|
+
warnings: findUnrunPlaywrightConfigs(cwd, frontendPath).map(unrunConfigWarning),
|
|
965
998
|
};
|
|
966
999
|
}
|
|
967
1000
|
|
|
968
1001
|
const commandLine = `${spec.command} ${spec.args.join(' ')}`;
|
|
969
1002
|
const run = exec(spec.command, spec.args, { cwd: spec.cwd, timeout: specTimeout, env: composeEnv });
|
|
1003
|
+
// Read from the FULL output, before the tail cut; unparseable is null, never 0.
|
|
1004
|
+
const count = typeof countSpecs === 'function' ? (countSpecs(run.output) ?? null) : null;
|
|
970
1005
|
|
|
971
1006
|
if (run.code !== 0) {
|
|
972
1007
|
return {
|
|
@@ -974,6 +1009,7 @@ function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv
|
|
|
974
1009
|
command: commandLine,
|
|
975
1010
|
output: tailLines(run.output, OUTPUT_TAIL_LINES),
|
|
976
1011
|
reason: 'e2e specs failed against the running stack',
|
|
1012
|
+
count,
|
|
977
1013
|
};
|
|
978
1014
|
}
|
|
979
1015
|
|
|
@@ -982,6 +1018,7 @@ function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv
|
|
|
982
1018
|
command: commandLine,
|
|
983
1019
|
output: '',
|
|
984
1020
|
reason: notes.length ? notes.join('; ') : null,
|
|
1021
|
+
count,
|
|
985
1022
|
};
|
|
986
1023
|
}
|
|
987
1024
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const ERRATA_HEADING = '## Errata';
|
|
2
|
+
|
|
3
|
+
/** Strip BOM and CRLF — the one normalization every reader of a frozen markdown applies. */
|
|
4
|
+
export function normalizeMarkdown(text) {
|
|
5
|
+
return String(text ?? '').replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lines with fenced blocks marked, so no heading is read inside ``` or ~~~.
|
|
10
|
+
*
|
|
11
|
+
* @returns {{ raw: string, n: number, fenced: boolean }[]}
|
|
12
|
+
*/
|
|
13
|
+
export function markdownLines(text) {
|
|
14
|
+
const out = [];
|
|
15
|
+
let fence = null;
|
|
16
|
+
normalizeMarkdown(text).split('\n').forEach((raw, i) => {
|
|
17
|
+
const opener = /^\s*(`{3,}|~{3,})/.exec(raw);
|
|
18
|
+
let fenced = Boolean(fence);
|
|
19
|
+
if (fence) {
|
|
20
|
+
if (opener && opener[1][0] === fence[0] && opener[1].length >= fence.length) fence = null;
|
|
21
|
+
} else if (opener) {
|
|
22
|
+
fence = opener[1];
|
|
23
|
+
fenced = true;
|
|
24
|
+
}
|
|
25
|
+
out.push({ raw, n: i + 1, fenced });
|
|
26
|
+
});
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isErrataHeading = (line) => !line.fenced
|
|
31
|
+
&& /^##\s+errata\s*$/i.test(line.raw);
|
|
32
|
+
|
|
33
|
+
/** Offset of the `## Errata` heading in the NORMALIZED text, or -1. */
|
|
34
|
+
export function errataOffset(text) {
|
|
35
|
+
let offset = 0;
|
|
36
|
+
for (const line of markdownLines(text)) {
|
|
37
|
+
if (isErrataHeading(line)) return offset;
|
|
38
|
+
offset += line.raw.length + 1;
|
|
39
|
+
}
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Everything before the errata block, normalized, without trailing blank. */
|
|
44
|
+
export function frozenBody(text) {
|
|
45
|
+
const norm = normalizeMarkdown(text);
|
|
46
|
+
const at = errataOffset(norm);
|
|
47
|
+
return (at === -1 ? norm : norm.slice(0, at)).trimEnd();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Is `proposed` the approved text plus an append-only errata? The body stays
|
|
52
|
+
* byte-identical, the result carries an errata block, and what the errata
|
|
53
|
+
* already said is never rewritten.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{ok: true} | {ok: false, why: string}}
|
|
56
|
+
*/
|
|
57
|
+
export function errataViolation(original, proposed) {
|
|
58
|
+
if (frozenBody(proposed) !== frozenBody(original)) {
|
|
59
|
+
return { ok: false, why: 'o corpo aprovado mudou — errata só ACRESCENTA ao fim, nunca altera o texto aprovado' };
|
|
60
|
+
}
|
|
61
|
+
const next = normalizeMarkdown(proposed);
|
|
62
|
+
const nextAt = errataOffset(next);
|
|
63
|
+
if (nextAt === -1) {
|
|
64
|
+
return { ok: false, why: `o conteúdo novo precisa estar sob um bloco \`${ERRATA_HEADING}\` no fim do arquivo` };
|
|
65
|
+
}
|
|
66
|
+
const prev = normalizeMarkdown(original);
|
|
67
|
+
const prevAt = errataOffset(prev);
|
|
68
|
+
if (prevAt !== -1 && !next.slice(nextAt).startsWith(prev.slice(prevAt).trimEnd())) {
|
|
69
|
+
return { ok: false, why: 'a errata existente foi reescrita — ela é append-only, entradas anteriores não se apagam' };
|
|
70
|
+
}
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
@@ -100,7 +100,7 @@ export function featureNamedIn(text, names) {
|
|
|
100
100
|
* know" as opposed to "the text names nothing in particular".
|
|
101
101
|
*
|
|
102
102
|
* This is what the single-feature fallback below was missing: a gate question
|
|
103
|
-
* for a feature not yet registered (no `create`, no `
|
|
103
|
+
* for a feature not yet registered (no `create`, no `plan.md` written
|
|
104
104
|
* yet — a fresh hotfix, say) names NO known feature either, and used to fall
|
|
105
105
|
* through to "the tree's one feature must be it". That is exactly how a Gate 3
|
|
106
106
|
* for `backfill-bool-filter` landed on `export-hub`, the tree's only
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The single list of frontend package locations: `src/lib/detectors` re-exports it,
|
|
2
|
+
// because a hook cannot import `src/`.
|
|
3
|
+
export const PACKAGE_LOCATIONS = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
|
|
4
|
+
|
|
5
|
+
// Where `verify`/`e2e` look for a Playwright setup when `project.frontendPath` is absent.
|
|
6
|
+
export const E2E_SPEC_LOCATIONS = [...PACKAGE_LOCATIONS, 'web'];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate a gate question asks about, or null. Explicit gate names win over
|
|
3
|
+
* words that may also appear in a feature slug ("plan", "design", "review").
|
|
4
|
+
*
|
|
5
|
+
* @param {string} question
|
|
6
|
+
* @returns {'plan'|'uiux'|'review'|null}
|
|
7
|
+
*/
|
|
8
|
+
export function gateFromQuestion(question) {
|
|
9
|
+
if (typeof question !== 'string') return null;
|
|
10
|
+
if (/gate\s+uiux/i.test(question)) return 'uiux';
|
|
11
|
+
if (/gate\s+3\b/i.test(question)) return 'review';
|
|
12
|
+
if (/gate\s+(?:[12]\b|d[oe]\s+plano|plan\b)/i.test(question)) return 'plan';
|
|
13
|
+
const lower = question.toLowerCase();
|
|
14
|
+
if (lower.includes('plano') || lower.includes('plan')) return 'plan';
|
|
15
|
+
if (lower.includes('design')) return 'uiux';
|
|
16
|
+
if (lower.includes('review') || lower.includes('ship')) return 'review';
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
@@ -8,13 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** Phase order */
|
|
11
|
-
export const PHASE_ORDER = ["
|
|
11
|
+
export const PHASE_ORDER = ["plan","uiux","implement","review","sync"];
|
|
12
12
|
|
|
13
13
|
/** Map phase → allowed output subdirectory */
|
|
14
14
|
export const PHASE_DIRS = {
|
|
15
|
-
proposal: '0-proposal',
|
|
16
|
-
uiux: '1-design',
|
|
17
15
|
plan: '2-plan',
|
|
16
|
+
uiux: '1-design',
|
|
18
17
|
implement: '3-implement',
|
|
19
18
|
review: '4-review',
|
|
20
19
|
sync: '3-implement',
|
|
@@ -22,57 +21,41 @@ export const PHASE_DIRS = {
|
|
|
22
21
|
|
|
23
22
|
/** Map output type (camelCase) → phase that produces it */
|
|
24
23
|
export const OUTPUT_PHASE_MAP = {
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
plan: 'plan',
|
|
25
|
+
checks: 'plan',
|
|
27
26
|
uiDesignSystem: 'uiux',
|
|
28
27
|
uiMockups: 'uiux',
|
|
29
28
|
uiComponents: 'uiux',
|
|
30
29
|
uiFlows: 'uiux',
|
|
31
|
-
spec: 'plan',
|
|
32
|
-
tasks: 'plan',
|
|
33
|
-
decisions: 'plan',
|
|
34
|
-
usecases: 'plan',
|
|
35
|
-
recap: 'implement',
|
|
36
30
|
evidencias: 'implement',
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
verifierReport: 'review',
|
|
32
|
+
reviewerReport: 'review',
|
|
33
|
+
securityReport: 'review',
|
|
39
34
|
};
|
|
40
35
|
|
|
41
36
|
/** Map filename → output type (camelCase) */
|
|
42
37
|
export const FILENAME_TO_OUTPUT = {
|
|
43
|
-
'
|
|
44
|
-
'
|
|
38
|
+
'plan.md': 'plan',
|
|
39
|
+
'checks.json': 'checks',
|
|
45
40
|
'design-system.md': 'uiDesignSystem',
|
|
46
41
|
'mockups.md': 'uiMockups',
|
|
47
42
|
'components.md': 'uiComponents',
|
|
48
43
|
'flows.md': 'uiFlows',
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
'recap.md': 'recap',
|
|
53
|
-
'review-report.md': 'reviewReport',
|
|
54
|
-
'evaluator-report.md': 'evaluatorReport',
|
|
44
|
+
'verifier.md': 'verifierReport',
|
|
45
|
+
'reviewer.md': 'reviewerReport',
|
|
46
|
+
'security.md': 'securityReport',
|
|
55
47
|
};
|
|
56
48
|
|
|
57
49
|
/** Protected spec files and the approval gate that locks them */
|
|
58
50
|
export const PROTECTED_SPEC_FILES = {
|
|
51
|
+
'plan.md': 'plan',
|
|
52
|
+
'checks.json': 'plan',
|
|
59
53
|
'design-system.md': 'uiux',
|
|
60
54
|
'mockups.md': 'uiux',
|
|
61
55
|
'components.md': 'uiux',
|
|
62
56
|
'flows.md': 'uiux',
|
|
63
|
-
'spec.md': 'plan',
|
|
64
|
-
'tasks.json': 'plan',
|
|
65
57
|
};
|
|
66
58
|
|
|
67
|
-
/**
|
|
68
|
-
* For protected files that carry mutable progress fields: filename → the
|
|
69
|
-
* top-level array-item fields that may change after the gate is approved.
|
|
70
|
-
* When a protected file is listed here, the protect-spec-files hook does a
|
|
71
|
-
* semantic diff (allowing only these fields to change) instead of a blanket block.
|
|
72
|
-
*/
|
|
73
|
-
export const MUTABLE_SPEC_FIELDS = {
|
|
74
|
-
'tasks.json': ['status', 'outputs', 'notes'],
|
|
75
|
-
};
|
|
76
59
|
|
|
77
60
|
/**
|
|
78
61
|
* Extract feature name from a .morph/features/{feature}/... path
|
|
@@ -88,7 +71,7 @@ export function extractFeatureName(filePath) {
|
|
|
88
71
|
/**
|
|
89
72
|
* Extract phase subdirectory from a .morph/features/{feature}/{phaseDir}/... path
|
|
90
73
|
* @param {string} filePath - File path to analyze
|
|
91
|
-
* @returns {string|null} Phase directory (e.g., '
|
|
74
|
+
* @returns {string|null} Phase directory (e.g., '2-plan', '1-design') or null
|
|
92
75
|
*/
|
|
93
76
|
export function extractPhaseDir(filePath) {
|
|
94
77
|
const normalized = filePath.replace(/\\/g, '/');
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
/** `PARCIAL` on the report's first non-blank line, tolerating heading/bold/quote typography. */
|
|
5
|
+
export const PARTIAL_MARKER = /^[\s#>*_]*PARCIAL\b/;
|
|
6
|
+
|
|
7
|
+
export const SLICE_STATUSES = ['pendente', 'parcial', 'feito'];
|
|
8
|
+
|
|
9
|
+
export function checksPath(featureDir) {
|
|
10
|
+
return join(featureDir, '2-plan', 'checks.json');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sliceReportPath(featureDir, sliceId) {
|
|
14
|
+
return join(featureDir, '3-implement', 'reports', `${sliceId}.md`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Status of a slice from its report text: no report = pendente, PARCIAL first line = parcial, else feito. */
|
|
18
|
+
export function sliceStatusFromReport(text) {
|
|
19
|
+
if (text == null) return 'pendente';
|
|
20
|
+
const first = String(text).split(/\r?\n/).find((l) => l.trim() !== '') ?? '';
|
|
21
|
+
return PARTIAL_MARKER.test(first) ? 'parcial' : 'feito';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Progress of a feature's slices.
|
|
26
|
+
*
|
|
27
|
+
* @param {string|null} checksText - the raw extracted checks file of the plan
|
|
28
|
+
* @param {(sliceId: string) => string|null} readReport - report text, null when absent
|
|
29
|
+
* @returns {{ total: number, done: number, partial: number, pending: number,
|
|
30
|
+
* slices: { id: string, title: string, depends: string[], covers: string[], status: string }[] } | null} null when that file is unreadable
|
|
31
|
+
*/
|
|
32
|
+
export function sliceProgressFrom(checksText, readReport) {
|
|
33
|
+
let slices;
|
|
34
|
+
try {
|
|
35
|
+
slices = JSON.parse(checksText ?? '').slices;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(slices)) return null;
|
|
40
|
+
const out = slices
|
|
41
|
+
.filter((s) => s && typeof s.id === 'string')
|
|
42
|
+
.map((s) => ({
|
|
43
|
+
id: s.id,
|
|
44
|
+
title: s.title ?? '',
|
|
45
|
+
depends: Array.isArray(s.depends) ? s.depends : [],
|
|
46
|
+
covers: Array.isArray(s.covers) ? s.covers : [],
|
|
47
|
+
status: sliceStatusFromReport(readReport(s.id)),
|
|
48
|
+
}));
|
|
49
|
+
const count = (status) => out.filter((s) => s.status === status).length;
|
|
50
|
+
return { total: out.length, done: count('feito'), partial: count('parcial'), pending: count('pendente'), slices: out };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readOrNull(path) {
|
|
54
|
+
try {
|
|
55
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Slice progress of the feature directory on disk. */
|
|
62
|
+
export function sliceProgress(featureDir) {
|
|
63
|
+
return sliceProgressFrom(readOrNull(checksPath(featureDir)), (id) => readOrNull(sliceReportPath(featureDir, id)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Unfinished slices whose dependencies are all done: what the next dispatch can take. */
|
|
67
|
+
export function readySlices(progress) {
|
|
68
|
+
if (!progress) return [];
|
|
69
|
+
const done = new Set(progress.slices.filter((s) => s.status === 'feito').map((s) => s.id));
|
|
70
|
+
return progress.slices.filter((s) => s.status !== 'feito' && s.depends.every((d) => done.has(d)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What a slice report must carry, besides prose and its covered scenarios.
|
|
75
|
+
* Loose on purpose: a false "complete" costs a read, a false "incomplete"
|
|
76
|
+
* re-dispatches finished work.
|
|
77
|
+
*/
|
|
78
|
+
export const REPORT_MARKERS = [
|
|
79
|
+
{ id: 'files', re: /^\s*(?:[-*]\s*)?(?:\*\*|#+\s*)?arquivos\s*:?\**\s*:?/mi },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const MIN_PROSE_CHARS = 80;
|
|
83
|
+
|
|
84
|
+
const scenarioRe = (id) => new RegExp(`(?<![\\w-])${id}(?![\\w-])`);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Inspect one report file: does it exist, which mandatory items it lacks,
|
|
88
|
+
* and whether it is partial.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} path
|
|
91
|
+
* @param {{ covers?: string[] }} [slice] - every scenario the slice covers must be named
|
|
92
|
+
*/
|
|
93
|
+
export function inspectReport(path, { covers = [] } = {}) {
|
|
94
|
+
const missingAll = ['what', 'files', ...covers];
|
|
95
|
+
if (!existsSync(path)) return { exists: false, complete: false, missing: missingAll };
|
|
96
|
+
let body = '';
|
|
97
|
+
try {
|
|
98
|
+
body = readFileSync(path, 'utf8');
|
|
99
|
+
} catch {
|
|
100
|
+
return { exists: true, complete: false, missing: missingAll, unreadable: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const missing = REPORT_MARKERS.filter((m) => !m.re.test(body)).map((m) => m.id);
|
|
104
|
+
missing.push(...covers.filter((id) => !scenarioRe(id).test(body)));
|
|
105
|
+
const prose = body
|
|
106
|
+
.split('\n')
|
|
107
|
+
.filter((l) => !/^\s*(#|[-*]\s|\*\*)/.test(l))
|
|
108
|
+
.join(' ')
|
|
109
|
+
.replace(/\s+/g, ' ')
|
|
110
|
+
.trim();
|
|
111
|
+
if (prose.length < MIN_PROSE_CHARS) missing.unshift('what');
|
|
112
|
+
|
|
113
|
+
const firstLine = body.split('\n').find((l) => l.trim() !== '') ?? '';
|
|
114
|
+
const partial = PARTIAL_MARKER.test(firstLine);
|
|
115
|
+
if (partial) missing.push('partial');
|
|
116
|
+
|
|
117
|
+
return { exists: true, complete: missing.length === 0, missing, partial };
|
|
118
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const NEEDS_CMD_SHIM = /^(npm|npx|pnpm|yarn)$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* On win32 `npx`/`npm`/… are `.cmd` shims: ENOENT without a shell, EINVAL as `.cmd`
|
|
8
|
+
* with `shell: false`. Playwright runs from its own JS entry, so it needs no shell.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveSpawn(file, args = [], {
|
|
11
|
+
cwd = process.cwd(),
|
|
12
|
+
platform = process.platform,
|
|
13
|
+
exists = existsSync,
|
|
14
|
+
execPath = process.execPath,
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (platform !== 'win32' || !NEEDS_CMD_SHIM.test(file)) return { file, args, shell: false };
|
|
17
|
+
|
|
18
|
+
if (file === 'npx' && args[0] === 'playwright') {
|
|
19
|
+
const cli = join(cwd, 'node_modules', '@playwright', 'test', 'cli.js');
|
|
20
|
+
if (exists(cli)) return { file: execPath, args: [cli, ...args.slice(1)], shell: false };
|
|
21
|
+
}
|
|
22
|
+
return { file: `${file}.cmd`, args, shell: true };
|
|
23
|
+
}
|