@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
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'fs';
|
|
2
|
-
import { getAbsoluteOutputPath } from '../core/paths/output-schema.js';
|
|
3
|
-
|
|
4
|
-
export const VERDICT_TOKEN = '**Verdict:**';
|
|
5
|
-
export const VERDICTS = ['SHIP', 'FIX'];
|
|
6
|
-
export const BLOCKER_TYPES = ['uc', 'teste', 'regressao', 'seguranca'];
|
|
7
|
-
|
|
8
|
-
// O relatório é append-only: uma reavaliação acrescenta ao fim, e o token vigente é o ÚLTIMO.
|
|
9
|
-
const VERDICT_RE = /\*\*Verdict:\*\*\s*([A-Za-z]+)/g;
|
|
10
|
-
const BLOCKERS_SECTION_RE = /^##\s+Bloqueadores\s*$/gim;
|
|
11
|
-
const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
|
|
12
|
-
const SCREEN_CHECKLIST_SECTION_RE = /^##\s+Checklist\s+de\s+tela\s*$/gim;
|
|
13
|
-
|
|
14
|
-
export function evaluatorReportPath(cwd, featureName) {
|
|
15
|
-
return getAbsoluteOutputPath(cwd, featureName, 'evaluatorReport');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function readReport(cwd, featureName) {
|
|
19
|
-
try {
|
|
20
|
-
const p = evaluatorReportPath(cwd, featureName);
|
|
21
|
-
return existsSync(p) ? readFileSync(p, 'utf8') : null;
|
|
22
|
-
} catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function lastSectionBody(text, re) {
|
|
28
|
-
const matches = [...text.matchAll(re)];
|
|
29
|
-
if (matches.length === 0) return null;
|
|
30
|
-
const last = matches[matches.length - 1];
|
|
31
|
-
const rest = text.slice(last.index + last[0].length);
|
|
32
|
-
const next = rest.search(/^#{1,2}\s/m);
|
|
33
|
-
return next === -1 ? rest : rest.slice(0, next);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const normalizeType = (s) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim();
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Linhas de tabela da última `## Bloqueadores`: `| id | tipo | onde | evidência |`.
|
|
40
|
-
* Cabeçalho e separador não contam; `untyped` são as linhas cujo tipo não é de {@link BLOCKER_TYPES}.
|
|
41
|
-
*/
|
|
42
|
-
function parseBlockers(body) {
|
|
43
|
-
const blockers = [];
|
|
44
|
-
const untyped = [];
|
|
45
|
-
for (const line of body.split(/\r?\n/)) {
|
|
46
|
-
if (!/^\s*\|/.test(line)) continue;
|
|
47
|
-
const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
|
|
48
|
-
if (cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '')) continue;
|
|
49
|
-
if (/^#$|^id$/i.test(cells[0]) && /^tipo$/i.test(cells[1] ?? '')) continue;
|
|
50
|
-
const row = { id: cells[0], type: normalizeType(cells[1] ?? ''), where: cells[2] ?? '', evidence: cells[3] ?? '' };
|
|
51
|
-
(BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
|
|
52
|
-
}
|
|
53
|
-
return { blockers, untyped };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function evaluatorVerdictFromText(text) {
|
|
57
|
-
if (typeof text !== 'string') return { verdict: undefined, blockers: [], untyped: [], superseded: [] };
|
|
58
|
-
const verdicts = [...text.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
|
|
59
|
-
const body = lastSectionBody(text, BLOCKERS_SECTION_RE);
|
|
60
|
-
const { blockers, untyped } = body === null ? { blockers: [], untyped: [] } : parseBlockers(body);
|
|
61
|
-
return {
|
|
62
|
-
verdict: verdicts.length ? verdicts[verdicts.length - 1] : undefined,
|
|
63
|
-
blockers,
|
|
64
|
-
untyped,
|
|
65
|
-
superseded: verdicts.slice(0, -1),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* `missing`: sem relatório. `unverdicted`: relatório sem `**Verdict:** SHIP|FIX` válido.
|
|
71
|
-
* `ok`: veredito legível — quem decide se ele libera o ship é o risk-detector.
|
|
72
|
-
*/
|
|
73
|
-
export function evaluatorReportState(cwd, featureName) {
|
|
74
|
-
const path = evaluatorReportPath(cwd, featureName);
|
|
75
|
-
const text = readReport(cwd, featureName);
|
|
76
|
-
if (text === null) return { status: 'missing', path };
|
|
77
|
-
const parsed = evaluatorVerdictFromText(text);
|
|
78
|
-
if (!VERDICTS.includes(parsed.verdict)) return { status: 'unverdicted', path };
|
|
79
|
-
return { status: 'ok', path, ...parsed };
|
|
80
|
-
}
|
|
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
|
-
|
|
95
|
-
export function evaluatorMediaSection(cwd, featureName) {
|
|
96
|
-
const text = readReport(cwd, featureName);
|
|
97
|
-
return text !== null && MEDIA_SECTION_RE.test(text);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function evaluatorScreenChecklist(cwd, featureName) {
|
|
101
|
-
const text = readReport(cwd, featureName);
|
|
102
|
-
const body = text === null ? null : lastSectionBody(text, SCREEN_CHECKLIST_SECTION_RE);
|
|
103
|
-
return body === null ? { section: false, body: '' } : { section: true, body };
|
|
104
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The shapes every reader and writer of the acervo agrees on — one copy each.
|
|
3
|
-
* A second copy of any of these is how two readers start disagreeing about the
|
|
4
|
-
* same file: a use case one of them sees and the other does not, a table row
|
|
5
|
-
* one of them splits differently.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { existsSync, readdirSync } from 'fs';
|
|
9
|
-
import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
|
|
10
|
-
|
|
11
|
-
/** A use-case id: `UC-07`. */
|
|
12
|
-
export const UC_ID_RE = /^UC-\d+$/;
|
|
13
|
-
|
|
14
|
-
/** A use case no longer holds as the system's behaviour (or holds only in its `scope`). */
|
|
15
|
-
export const SUPERSEDED = 'superseded';
|
|
16
|
-
|
|
17
|
-
const UC_FILENAME_RE = new RegExp(OUTPUT_SCHEMA.usecases.filenamePattern);
|
|
18
|
-
|
|
19
|
-
/** `UC-07-listar-veterinarios.md` → `UC-07`. Null when the name isn't a use case. */
|
|
20
|
-
export function useCaseId(filename) {
|
|
21
|
-
const m = /^(UC-\d+)-/.exec(filename);
|
|
22
|
-
return m ? m[1] : null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** The name the promotion carries: `UC-{nn}-{slug}.md`, slug in lowercase kebab. */
|
|
26
|
-
export const isCanonicalUseCaseName = (name) => UC_FILENAME_RE.test(name);
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Every use case in `dir`, grouped by id. By default only the canonical names
|
|
30
|
-
* — the ones the promotion ever carries. `canonicalOnly: false` also lists any
|
|
31
|
-
* `UC-{nn}-….md`, for the plan-time check that has always accepted them and
|
|
32
|
-
* now warns instead. An id with more than one file is a broken acervo, and
|
|
33
|
-
* readers must be able to see it rather than silently pick one.
|
|
34
|
-
*
|
|
35
|
-
* @param {string} dir
|
|
36
|
-
* @param {{ canonicalOnly?: boolean }} [options]
|
|
37
|
-
* @returns {Map<string, string[]>} id → filenames, sorted; empty when the folder is missing or unreadable
|
|
38
|
-
*/
|
|
39
|
-
export function useCaseFilesById(dir, { canonicalOnly = true } = {}) {
|
|
40
|
-
const byId = new Map();
|
|
41
|
-
try {
|
|
42
|
-
if (!existsSync(dir)) return byId;
|
|
43
|
-
for (const name of readdirSync(dir).sort()) {
|
|
44
|
-
const id = useCaseId(name);
|
|
45
|
-
if (!id || !name.endsWith('.md')) continue;
|
|
46
|
-
if (canonicalOnly && !isCanonicalUseCaseName(name)) continue;
|
|
47
|
-
if (!byId.has(id)) byId.set(id, []);
|
|
48
|
-
byId.get(id).push(name);
|
|
49
|
-
}
|
|
50
|
-
} catch { /* unreadable — nothing to see */ }
|
|
51
|
-
return byId;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** A markdown table separator row: `|---|:--:|`, with or without the outer pipes. */
|
|
55
|
-
export const SEPARATOR_ROW_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
|
|
56
|
-
|
|
57
|
-
/** A line that belongs to a table: it carries an unescaped pipe (GFM does not require the leading one). */
|
|
58
|
-
export const isTableLine = (line) => /(?<!\\)\|/.test(line);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Split a table row on unescaped pipes, outer pipes optional; `\|` inside a
|
|
62
|
-
* cell is a literal pipe.
|
|
63
|
-
*
|
|
64
|
-
* @param {string} row
|
|
65
|
-
* @returns {string[]} trimmed cells
|
|
66
|
-
*/
|
|
67
|
-
export function tableCells(row) {
|
|
68
|
-
const inner = row.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '');
|
|
69
|
-
return inner.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, '|').trim());
|
|
70
|
-
}
|
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lifecycle of the project's acervo: a use case stops being the current
|
|
3
|
-
* behaviour when a later feature supersedes it.
|
|
4
|
-
*
|
|
5
|
-
* status: superseded
|
|
6
|
-
* superseded_by: UC-589
|
|
7
|
-
* scope: "vitrine pública, fora de `.morph-admin-shell`" ← only when partial
|
|
8
|
-
*
|
|
9
|
-
* Three roles live here:
|
|
10
|
-
* - the ONE rule for that frontmatter (`supersessionProblems`) — the check
|
|
11
|
-
* reads it, and the writer below only ever produces what it accepts;
|
|
12
|
-
* - the close-time writer (`applySpecRevocations`): what the feature's
|
|
13
|
-
* `spec.md` declared it revokes lands on `.morph/specs/usecases/` and on
|
|
14
|
-
* the ADR index, fail-open like the promotion it follows;
|
|
15
|
-
* - the plan-time check (`checkLifecycle`), shared by `dag` and `usecase
|
|
16
|
-
* check`, run while `spec.md` can still be fixed.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { readFileSync, writeFileSync } from 'fs';
|
|
20
|
-
import { join } from 'path';
|
|
21
|
-
import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
|
|
22
|
-
import {
|
|
23
|
-
SPECS_USECASES_PATHSPEC,
|
|
24
|
-
hasFrontmatter,
|
|
25
|
-
readFrontmatterField,
|
|
26
|
-
removeFrontmatterField,
|
|
27
|
-
setFrontmatterField,
|
|
28
|
-
} from './usecase-promoter.js';
|
|
29
|
-
import { SUPERSEDED, UC_ID_RE, useCaseFilesById } from './acervo-format.js';
|
|
30
|
-
import { parseRevocations } from './revocations.js';
|
|
31
|
-
import { ADR_INDEX_PATHSPEC, readAdrIndex, updateAdrIndex } from './adr-index.js';
|
|
32
|
-
|
|
33
|
-
export { SUPERSEDED };
|
|
34
|
-
|
|
35
|
-
const UC = OUTPUT_SCHEMA.usecases;
|
|
36
|
-
const ACERVO_LABEL = SPECS_USECASES_PATHSPEC.replace(/\\/g, '/');
|
|
37
|
-
const ADR_INDEX_LABEL = `${ADR_INDEX_PATHSPEC.replace(/\\/g, '/')}/`;
|
|
38
|
-
|
|
39
|
-
/** @returns {{ status: string|null, supersededBy: string|null, scope: string|null }} */
|
|
40
|
-
export function lifecycleOf(content) {
|
|
41
|
-
return {
|
|
42
|
-
status: readFrontmatterField(content, 'status'),
|
|
43
|
-
supersededBy: readFrontmatterField(content, 'superseded_by'),
|
|
44
|
-
scope: readFrontmatterField(content, 'scope'),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The rule for a use case's supersession frontmatter.
|
|
50
|
-
*
|
|
51
|
-
* @param {string} content
|
|
52
|
-
* @param {string} id - the use case's own id
|
|
53
|
-
* @returns {string[]} one line per problem; `[]` = well formed
|
|
54
|
-
*/
|
|
55
|
-
export function supersessionProblems(content, id) {
|
|
56
|
-
const { status, supersededBy, scope } = lifecycleOf(content);
|
|
57
|
-
const problems = [];
|
|
58
|
-
if (status === SUPERSEDED) {
|
|
59
|
-
if (!supersededBy) problems.push('status: superseded sem superseded_by');
|
|
60
|
-
else if (!UC_ID_RE.test(supersededBy)) problems.push(`superseded_by "${supersededBy}" não é um UC-{nn}`);
|
|
61
|
-
else if (supersededBy === id) problems.push('superseded_by aponta para o próprio use case');
|
|
62
|
-
} else if (supersededBy !== null || scope !== null) {
|
|
63
|
-
problems.push(`${supersededBy !== null ? 'superseded_by' : 'scope'} sem status: superseded`);
|
|
64
|
-
}
|
|
65
|
-
if (scope !== null && !String(scope).trim()) problems.push('scope vazio');
|
|
66
|
-
return problems;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function readOrNull(path) {
|
|
70
|
-
try { return readFileSync(path, 'utf8'); } catch { return null; }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const specPathOf = (featureDir) => join(featureDir, OUTPUT_SCHEMA.spec.phaseDir, OUTPUT_SCHEMA.spec.filename);
|
|
74
|
-
|
|
75
|
-
/** A finished feature cannot be re-closed: the fix is the next feature's revocation row. */
|
|
76
|
-
const FIX_NEXT_TIME = 'corrija a linha na seção de revogações do spec.md — a feature já arquivada não é reprocessada, ' +
|
|
77
|
-
'então a revogação corrigida entra no spec.md da próxima feature';
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Mark the acervo use cases the entries revoke. Runs after the promotion, so
|
|
81
|
-
* the acervo already holds every substitute this feature brought — except the
|
|
82
|
-
* ones the promotion refused, which only LOOK present (the file under that id
|
|
83
|
-
* belongs to another feature).
|
|
84
|
-
*
|
|
85
|
-
* @returns {{ superseded: Array<{id: string, file: string, supersededBy: string, scope?: string}>,
|
|
86
|
-
* problems: Array<{id: string, reason: string, message: string}> }}
|
|
87
|
-
*/
|
|
88
|
-
function supersedeUseCases(cwd, entries, refusedIds) {
|
|
89
|
-
const superseded = [];
|
|
90
|
-
const problems = [];
|
|
91
|
-
if (!entries.length) return { superseded, problems };
|
|
92
|
-
|
|
93
|
-
const dir = join(cwd, SPECS_USECASES_PATHSPEC);
|
|
94
|
-
const files = useCaseFilesById(dir);
|
|
95
|
-
const problem = (id, reason, message) => problems.push({ id, reason, message });
|
|
96
|
-
|
|
97
|
-
for (const e of entries) {
|
|
98
|
-
const targets = files.get(e.id) ?? [];
|
|
99
|
-
if (targets.length === 0) {
|
|
100
|
-
problem(e.id, 'not-in-acervo', `${e.id} não está no acervo (${ACERVO_LABEL}/) — nada foi marcado; ${FIX_NEXT_TIME}`);
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (targets.length > 1) {
|
|
104
|
-
problem(e.id, 'duplicate-in-acervo',
|
|
105
|
-
`${e.id} tem ${targets.length} arquivos no acervo (${targets.join(', ')}) — nenhum foi marcado; o acervo precisa de um arquivo por UC`);
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (refusedIds.has(e.substitute)) {
|
|
109
|
-
problem(e.id, 'substitute-refused',
|
|
110
|
-
`o substituto ${e.substitute} de ${e.id} não foi promovido (colisão de numeração) — ${e.id} não foi marcado`);
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (!files.has(e.substitute)) {
|
|
114
|
-
problem(e.id, 'substitute-not-found',
|
|
115
|
-
`o substituto ${e.substitute} de ${e.id} não está no acervo — ${e.id} não foi marcado; ${FIX_NEXT_TIME}`);
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const file = targets[0];
|
|
120
|
-
try {
|
|
121
|
-
const path = join(dir, file);
|
|
122
|
-
const before = readFileSync(path, 'utf8');
|
|
123
|
-
if (!hasFrontmatter(before)) {
|
|
124
|
-
problem(e.id, 'no-frontmatter', `${ACERVO_LABEL}/${file} não tem frontmatter — o status não foi aplicado`);
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
let next = setFrontmatterField(before, 'status', SUPERSEDED);
|
|
128
|
-
next = setFrontmatterField(next, 'superseded_by', e.substitute);
|
|
129
|
-
// JSON-quoted: free text with `: ` or a leading backtick breaks a bare YAML scalar.
|
|
130
|
-
next = e.scope
|
|
131
|
-
? setFrontmatterField(next, 'scope', JSON.stringify(e.scope))
|
|
132
|
-
: removeFrontmatterField(next, 'scope');
|
|
133
|
-
if (next !== before) writeFileSync(path, next, 'utf8');
|
|
134
|
-
superseded.push({ id: e.id, file, supersededBy: e.substitute, ...(e.scope ? { scope: e.scope } : {}) });
|
|
135
|
-
} catch {
|
|
136
|
-
problem(e.id, 'unwritable', `${ACERVO_LABEL}/${file} não pôde ser lido/gravado — o status não foi aplicado`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return { superseded, problems };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Apply, at close time, what the feature's `spec.md` declared it revokes.
|
|
144
|
-
* Fail-open: nothing here may refuse the close; what did not apply comes back
|
|
145
|
-
* in `problems`, next to the row's own parse problems.
|
|
146
|
-
*
|
|
147
|
-
* @param {string} cwd - project root that owns `.morph/specs/`
|
|
148
|
-
* @param {string} featureDir - the feature folder (already moved to the archive)
|
|
149
|
-
* @param {{ refused?: Array<{ id: string }> }} [promotion] - the promotion's result, run just before
|
|
150
|
-
* @returns {{ superseded: Array<{id: string, file: string, supersededBy: string, scope?: string}>,
|
|
151
|
-
* adrIndex: ReturnType<typeof updateAdrIndex>|null,
|
|
152
|
-
* problems: Array<{id: string|null, reason: string, message: string}> }}
|
|
153
|
-
*/
|
|
154
|
-
export function applySpecRevocations(cwd, featureDir, promotion = {}) {
|
|
155
|
-
const out = { superseded: [], adrIndex: null, problems: [] };
|
|
156
|
-
|
|
157
|
-
const spec = readOrNull(specPathOf(featureDir));
|
|
158
|
-
const parsed = spec === null ? { entries: [], problems: [] } : parseRevocations(spec);
|
|
159
|
-
out.problems.push(...parsed.problems);
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
const refusedIds = new Set((promotion.refused ?? []).map((r) => r.id));
|
|
163
|
-
const uc = supersedeUseCases(cwd, parsed.entries.filter((e) => e.kind === 'uc'), refusedIds);
|
|
164
|
-
out.superseded = uc.superseded;
|
|
165
|
-
out.problems.push(...uc.problems);
|
|
166
|
-
} catch { /* fail-open */ }
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
out.adrIndex = updateAdrIndex(cwd, featureDir, parsed.entries.filter((e) => e.kind === 'adr'));
|
|
170
|
-
out.problems.push(...out.adrIndex.problems);
|
|
171
|
-
} catch { /* fail-open */ }
|
|
172
|
-
|
|
173
|
-
return out;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Plan-time check of a feature against the acervo's lifecycle.
|
|
178
|
-
*
|
|
179
|
-
* Warnings only, never errors: a superseded use case is a question for the
|
|
180
|
-
* plan, and the revocation rows are re-reported at close time anyway.
|
|
181
|
-
*
|
|
182
|
-
* @param {{ cwd: string, featureDir: string, tasksData?: object|null }} input
|
|
183
|
-
* @returns {{ warnings: string[] }}
|
|
184
|
-
*/
|
|
185
|
-
export function checkLifecycle({ cwd, featureDir, tasksData = null }) {
|
|
186
|
-
const warnings = [];
|
|
187
|
-
const acervoDir = join(cwd, SPECS_USECASES_PATHSPEC);
|
|
188
|
-
const ownDir = join(featureDir, UC.phaseDir, UC.dirname);
|
|
189
|
-
const acervo = useCaseFilesById(acervoDir);
|
|
190
|
-
const own = useCaseFilesById(ownDir);
|
|
191
|
-
|
|
192
|
-
const ownLife = new Map();
|
|
193
|
-
for (const [id, files] of own) {
|
|
194
|
-
const file = files[files.length - 1];
|
|
195
|
-
const content = readOrNull(join(ownDir, file));
|
|
196
|
-
if (content === null) continue;
|
|
197
|
-
ownLife.set(id, lifecycleOf(content));
|
|
198
|
-
for (const p of supersessionProblems(content, id)) warnings.push(`2-plan/usecases/${file}: ${p}`);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// The acervo can hold hundreds of use cases: read only the ones this plan touches.
|
|
202
|
-
const acervoLife = new Map();
|
|
203
|
-
const acervoOf = (id) => {
|
|
204
|
-
if (acervoLife.has(id)) return acervoLife.get(id);
|
|
205
|
-
const files = acervo.get(id) ?? [];
|
|
206
|
-
if (files.length > 1) warnings.push(`${ACERVO_LABEL}/: ${id} tem ${files.length} arquivos (${files.join(', ')}) — o acervo precisa de um arquivo por UC`);
|
|
207
|
-
const file = files[files.length - 1];
|
|
208
|
-
const content = file ? readOrNull(join(acervoDir, file)) : null;
|
|
209
|
-
const life = content === null ? null : lifecycleOf(content);
|
|
210
|
-
if (content !== null) {
|
|
211
|
-
for (const p of supersessionProblems(content, id)) warnings.push(`${ACERVO_LABEL}/${file}: ${p}`);
|
|
212
|
-
}
|
|
213
|
-
acervoLife.set(id, life);
|
|
214
|
-
return life;
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
const spec = readOrNull(specPathOf(featureDir));
|
|
218
|
-
const rev = spec === null ? { entries: [], problems: [] } : parseRevocations(spec);
|
|
219
|
-
for (const p of rev.problems) warnings.push(`spec.md, revogações: ${p.message}`);
|
|
220
|
-
|
|
221
|
-
const revokedHere = new Map();
|
|
222
|
-
let indexedAdrs = null;
|
|
223
|
-
for (const e of rev.entries) {
|
|
224
|
-
if (e.kind === 'adr') {
|
|
225
|
-
indexedAdrs ??= new Set(readAdrIndex(cwd).map((r) => r.ref));
|
|
226
|
-
if (!indexedAdrs.has(e.id)) {
|
|
227
|
-
warnings.push(`spec.md, revogações: ${e.id} não está no índice de ADRs ativos (${ADR_INDEX_LABEL}) — o finish não terá o que retirar`);
|
|
228
|
-
}
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
revokedHere.set(e.id, e);
|
|
232
|
-
if (!acervo.has(e.id)) {
|
|
233
|
-
warnings.push(`spec.md, revogações: ${e.id} não está no acervo (${ACERVO_LABEL}/) — o finish não terá o que marcar`);
|
|
234
|
-
} else {
|
|
235
|
-
acervoOf(e.id);
|
|
236
|
-
}
|
|
237
|
-
if (!acervo.has(e.substitute) && !own.has(e.substitute)) {
|
|
238
|
-
warnings.push(`spec.md, revogações: o substituto ${e.substitute} de ${e.id} não existe no acervo nem em 2-plan/usecases/ — o finish não aplicará a linha`);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
|
|
243
|
-
for (const task of tasks) {
|
|
244
|
-
const id = task?.usecase;
|
|
245
|
-
if (!id) continue;
|
|
246
|
-
const inAcervo = acervoOf(id);
|
|
247
|
-
const inOwn = ownLife.get(id);
|
|
248
|
-
const here = revokedHere.get(id);
|
|
249
|
-
|
|
250
|
-
let where = null;
|
|
251
|
-
if (inAcervo?.status === SUPERSEDED) where = { src: 'está superseded no acervo', ...inAcervo };
|
|
252
|
-
else if (inOwn?.status === SUPERSEDED) where = { src: 'está superseded na cópia da feature', ...inOwn };
|
|
253
|
-
else if (here) where = { src: 'é revogado por esta feature (spec.md)', supersededBy: here.substitute, scope: here.scope };
|
|
254
|
-
if (!where) continue;
|
|
255
|
-
|
|
256
|
-
const by = where.supersededBy;
|
|
257
|
-
const detail = [by ? `substituto: ${by}` : 'sem substituto declarado', where.scope ? `ainda vale em: ${where.scope}` : null]
|
|
258
|
-
.filter(Boolean).join('; ');
|
|
259
|
-
const advice = where.scope ? 'confira que a task realiza a parte que ainda vale' : by ? `cite ${by}` : 'confira o acervo';
|
|
260
|
-
warnings.push(`${task.id}.usecase → ${id} ${where.src} (${detail}) — ${advice}`);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return { warnings };
|
|
264
|
-
}
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lexical heuristic: does a task's `doneCriteria` describe an ARTIFACT (a
|
|
3
|
-
* file, a grep, a binary) instead of behaviour observable at runtime?
|
|
4
|
-
*
|
|
5
|
-
* A literal `doneCriteria` travels verbatim into the test that verifies it
|
|
6
|
-
* (`morph-implement` copies it, never paraphrases). A use-case post-condition
|
|
7
|
-
* shaped like "nenhum arquivo em X contém Y" — even a literal `grep` command —
|
|
8
|
-
* becomes a test that reads file text instead of exercising behaviour. This
|
|
9
|
-
* is a WARNING, never an error: `dag`'s exit code never changes because of
|
|
10
|
-
* it, the same way `checkUseCases`'s coverage/literalness findings do not.
|
|
11
|
-
*
|
|
12
|
-
* Runs over EVERY task, unlike `usecase-check.js#checkUseCases` (which
|
|
13
|
-
* short-circuits when the feature has no use cases): a task's own
|
|
14
|
-
* `doneCriteria` can describe an artifact with no use case in sight, and
|
|
15
|
-
* that must still be caught. This is why it lives in its own file — it is a
|
|
16
|
-
* standalone lexical pass, not wiring between use cases and tasks.
|
|
17
|
-
*
|
|
18
|
-
* Two ways a match is silenced:
|
|
19
|
-
* - a task WITHOUT a use case can justify itself inline, with
|
|
20
|
-
* `Por que artefato: …` anywhere in its own `doneCriteria`. A task WITH a
|
|
21
|
-
* use case never can — it justifies in the use case, otherwise the marker
|
|
22
|
-
* would silence every post-condition it carries at once;
|
|
23
|
-
* - a task carrying a use-case post-condition literally is silenced only
|
|
24
|
-
* if THAT post-condition was justified in the use case document (the
|
|
25
|
-
* `> [!note] Por que artefato:` callout `usecase-check.js` parses).
|
|
26
|
-
* Justified text is removed from the task's `doneCriteria` before scanning —
|
|
27
|
-
* not skipped as a whole task — so a justified P1 never silences an
|
|
28
|
-
* unjustified P2 carried in the same `doneCriteria`. The scan runs on the
|
|
29
|
-
* whitespace-collapsed text with its CASE PRESERVED: an uppercase format
|
|
30
|
-
* acronym (`arquivo em CSV`) is the only thing that tells an export from a
|
|
31
|
-
* directory (`arquivo em src`), and lowercasing first would erase it.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { ARTIFACT_MARKER_RE, loadUseCases, normalize } from './usecase-check.js';
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Case-insensitive spelling of `word` inside a regex that must stay
|
|
38
|
-
* case-SENSITIVE elsewhere — Node 18 has no inline `(?i:…)` modifier.
|
|
39
|
-
*/
|
|
40
|
-
const anyCase = (word) => [...word].map((c) => {
|
|
41
|
-
const lo = c.toLowerCase();
|
|
42
|
-
const up = c.toUpperCase();
|
|
43
|
-
return lo === up ? c : `[${lo}${up}]`;
|
|
44
|
-
}).join('');
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* What follows "arquivo em" in a behavioural export criterion ("exporta o
|
|
48
|
-
* arquivo em CSV", "salva o arquivo em formato aberto"): an uppercase file
|
|
49
|
-
* format acronym or the word `formato`. Case-sensitive on purpose — `src`,
|
|
50
|
-
* `docs` or `lib` after "arquivo em" are places, and places are artifacts.
|
|
51
|
-
*/
|
|
52
|
-
const FORMAT_TOKEN = String.raw`\s+(?:[A-Z]{2,5}(?![\p{L}\d_])|${anyCase('formato')}(?![\p{L}]))`;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Vocabulary of artifact-shaped phrasing. `term` is the label surfaced in
|
|
56
|
-
* warnings and cited by the `morph-plan` skill prose — keep it stable, a
|
|
57
|
-
* later parity test imports it verbatim.
|
|
58
|
-
*
|
|
59
|
-
* Unicode lookarounds, never `\b`: in JS, `\b` does not treat accented
|
|
60
|
-
* letters (é, á, ó…) as word characters, so it would false-boundary inside
|
|
61
|
-
* "não" or "binário".
|
|
62
|
-
*/
|
|
63
|
-
export const ARTIFACT_VOCABULARY = [
|
|
64
|
-
{ term: 'grep', re: /(?<![\p{L}\d_])grep(?![\p{L}\d_])/iu },
|
|
65
|
-
{ term: 'contém a string', re: /cont[ée]m\s+as?\s+strings?/iu },
|
|
66
|
-
{ term: 'o binário', re: /(?<![\p{L}])o\s+bin[áa]rio(?![\p{L}])/iu },
|
|
67
|
-
{ term: 'nenhum arquivo', re: /(?<![\p{L}])nenhum\s+arquivo(?![\p{L}])/iu },
|
|
68
|
-
{
|
|
69
|
-
term: 'arquivo/pasta/diretório em',
|
|
70
|
-
re: new RegExp(
|
|
71
|
-
String.raw`(?<![\p{L}])(?:${anyCase('arquivo')}|${anyCase('pasta')}|${anyCase('diret')}[óoÓO]${anyCase('rio')})` +
|
|
72
|
-
String.raw`[sS]?\s+${anyCase('em')}(?![\p{L}])(?!${FORMAT_TOKEN})`,
|
|
73
|
-
'u',
|
|
74
|
-
),
|
|
75
|
-
},
|
|
76
|
-
{ term: 'glob **/', re: /\*\*\/|\/\*\*/ },
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Vocabulary terms matched in `text`, in vocabulary order, each at most once.
|
|
81
|
-
*
|
|
82
|
-
* @param {string} text
|
|
83
|
-
* @returns {string[]}
|
|
84
|
-
*/
|
|
85
|
-
export function findArtifactTerms(text) {
|
|
86
|
-
const s = String(text ?? '');
|
|
87
|
-
const found = [];
|
|
88
|
-
for (const { term, re } of ARTIFACT_VOCABULARY) {
|
|
89
|
-
if (re.test(s)) found.push(term);
|
|
90
|
-
}
|
|
91
|
-
return found;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Whitespace-collapsed, case PRESERVED — `normalize` without the lowercasing. */
|
|
95
|
-
function collapse(text) {
|
|
96
|
-
return String(text).replace(/\s+/g, ' ').trim();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Remove every case-insensitive occurrence of `needle` (already normalized)
|
|
101
|
-
* from `text`, keeping the case of what remains. Relies on `text` and its
|
|
102
|
-
* lowercase form being index-aligned — the caller guarantees it.
|
|
103
|
-
*/
|
|
104
|
-
function cut(text, needle) {
|
|
105
|
-
if (!needle) return text;
|
|
106
|
-
const lower = text.toLowerCase();
|
|
107
|
-
let out = '';
|
|
108
|
-
let from = 0;
|
|
109
|
-
let at;
|
|
110
|
-
while ((at = lower.indexOf(needle, from)) !== -1) {
|
|
111
|
-
out += `${text.slice(from, at)} `;
|
|
112
|
-
from = at + needle.length;
|
|
113
|
-
}
|
|
114
|
-
return out + text.slice(from);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Flag every task whose `doneCriteria` describes an artifact rather than
|
|
119
|
-
* behaviour, after removing use-case text the author already justified.
|
|
120
|
-
*
|
|
121
|
-
* Fail-open: a missing `usecasesDir` still checks every task (it just has no
|
|
122
|
-
* justifications or post-conditions to consult); a malformed `tasksData`
|
|
123
|
-
* (no `tasks` array) or a non-string `doneCriteria` never throws.
|
|
124
|
-
*
|
|
125
|
-
* @param {Object} tasksData - parsed tasks.json
|
|
126
|
-
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
127
|
-
* @returns {{ warnings: string[], flagged: Array<{ task: string, usecase: string|null, labels: string[], terms: string[] }>, checked: true }}
|
|
128
|
-
*/
|
|
129
|
-
export function checkArtifactCriteria(tasksData, usecasesDir) {
|
|
130
|
-
const warnings = [];
|
|
131
|
-
const flagged = [];
|
|
132
|
-
const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
|
|
133
|
-
|
|
134
|
-
let byId = new Map();
|
|
135
|
-
if (typeof usecasesDir === 'string') {
|
|
136
|
-
try { byId = loadUseCases(usecasesDir); } catch { byId = new Map(); }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
for (const task of tasks) {
|
|
140
|
-
if (typeof task?.doneCriteria !== 'string') continue;
|
|
141
|
-
|
|
142
|
-
let D = collapse(task.doneCriteria);
|
|
143
|
-
if (!D) continue;
|
|
144
|
-
// `cut` needs `D` index-aligned with its lowercase form. The rare letter
|
|
145
|
-
// whose lowercase is longer (`İ`) breaks that; fall back to the lowercase
|
|
146
|
-
// text, which only costs the format-acronym exception for that task.
|
|
147
|
-
if (D.toLowerCase().length !== D.length) D = D.toLowerCase();
|
|
148
|
-
|
|
149
|
-
// Inline justification is for tasks WITHOUT a use case (morph-plan: "Task
|
|
150
|
-
// sem `usecase` justifica no próprio doneCriteria"). On a use-case task it
|
|
151
|
-
// would silence every post-condition carried, justified or not.
|
|
152
|
-
if (!task.usecase && ARTIFACT_MARKER_RE.test(D)) continue;
|
|
153
|
-
|
|
154
|
-
const uc = task.usecase ? byId.get(task.usecase) : null;
|
|
155
|
-
const unjustifiedLabels = [];
|
|
156
|
-
|
|
157
|
-
if (uc) {
|
|
158
|
-
// Remove justified post-condition text first, so its own vocabulary
|
|
159
|
-
// hits (if any) never surface — and so it cannot mask what remains.
|
|
160
|
-
// A wrapped post-condition is removed in both forms a plan legitimately
|
|
161
|
-
// carries: the whole block, or only its first line (what `checkUseCases`
|
|
162
|
-
// treats as THE post-condition). Longest first.
|
|
163
|
-
for (const pc of uc.postConditions) {
|
|
164
|
-
if (!uc.artifactJustified.has(pc.label)) continue;
|
|
165
|
-
for (const text of [uc.artifactJustified.get(pc.label), pc.text]) {
|
|
166
|
-
D = cut(D, normalize(text));
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
// Among what is left, name the unjustified post-conditions actually
|
|
170
|
-
// carried into this doneCriteria AND that themselves read as artifact —
|
|
171
|
-
// judged on the whole block, since a wrapped post-condition can carry
|
|
172
|
-
// its vocabulary on the continuation line.
|
|
173
|
-
const carried = D.toLowerCase();
|
|
174
|
-
for (const pc of uc.postConditions) {
|
|
175
|
-
if (uc.artifactJustified.has(pc.label)) continue;
|
|
176
|
-
const needle = normalize(pc.text);
|
|
177
|
-
const block = uc.fullText.get(pc.label) ?? pc.text;
|
|
178
|
-
if (needle && carried.includes(needle) && findArtifactTerms(block).length > 0) {
|
|
179
|
-
unjustifiedLabels.push(pc.label);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const terms = findArtifactTerms(D);
|
|
185
|
-
if (terms.length === 0) continue;
|
|
186
|
-
|
|
187
|
-
const quoted = terms.join(', ');
|
|
188
|
-
const message = unjustifiedLabels.length > 0
|
|
189
|
-
? `${task.id}.doneCriteria descreve artefato, não comportamento ("${quoted}") — ` +
|
|
190
|
-
`reescreva ${task.usecase} ${unjustifiedLabels.join('/')} como efeito observável em runtime, ` +
|
|
191
|
-
`ou justifique com "> [!note] Por que artefato:" na linha abaixo dela`
|
|
192
|
-
: `${task.id}.doneCriteria descreve artefato, não comportamento ("${quoted}") — ` +
|
|
193
|
-
`reescreva como efeito observável em runtime, ou acrescente "Por que artefato: …" ao próprio critério`;
|
|
194
|
-
|
|
195
|
-
warnings.push(message);
|
|
196
|
-
flagged.push({ task: task.id, usecase: task.usecase || null, labels: unjustifiedLabels, terms });
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return { warnings, flagged, checked: true };
|
|
200
|
-
}
|