cap-pro 1.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-plugin/README.md +26 -0
- package/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.ja-JP.md +834 -0
- package/README.ko-KR.md +823 -0
- package/README.md +806 -0
- package/README.pt-BR.md +452 -0
- package/README.zh-CN.md +800 -0
- package/agents/cap-architect.md +269 -0
- package/agents/cap-brainstormer.md +207 -0
- package/agents/cap-curator.md +276 -0
- package/agents/cap-debugger.md +365 -0
- package/agents/cap-designer.md +246 -0
- package/agents/cap-historian.md +464 -0
- package/agents/cap-migrator.md +291 -0
- package/agents/cap-prototyper.md +197 -0
- package/agents/cap-validator.md +308 -0
- package/bin/install.js +5433 -0
- package/cap/bin/cap-tools.cjs +853 -0
- package/cap/bin/lib/arc-scanner.cjs +344 -0
- package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
- package/cap/bin/lib/cap-anchor.cjs +228 -0
- package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
- package/cap/bin/lib/cap-checkpoint.cjs +434 -0
- package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
- package/cap/bin/lib/cap-cluster-display.cjs +52 -0
- package/cap/bin/lib/cap-cluster-format.cjs +245 -0
- package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
- package/cap/bin/lib/cap-cluster-io.cjs +212 -0
- package/cap/bin/lib/cap-completeness.cjs +540 -0
- package/cap/bin/lib/cap-deps.cjs +583 -0
- package/cap/bin/lib/cap-design-families.cjs +332 -0
- package/cap/bin/lib/cap-design.cjs +966 -0
- package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
- package/cap/bin/lib/cap-doctor.cjs +752 -0
- package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
- package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
- package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
- package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
- package/cap/bin/lib/cap-feature-map.cjs +1943 -0
- package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
- package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
- package/cap/bin/lib/cap-learn-review.cjs +1072 -0
- package/cap/bin/lib/cap-learning-signals.cjs +627 -0
- package/cap/bin/lib/cap-loader.cjs +227 -0
- package/cap/bin/lib/cap-logger.cjs +57 -0
- package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
- package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
- package/cap/bin/lib/cap-memory-dir.cjs +987 -0
- package/cap/bin/lib/cap-memory-engine.cjs +698 -0
- package/cap/bin/lib/cap-memory-extends.cjs +398 -0
- package/cap/bin/lib/cap-memory-graph.cjs +790 -0
- package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
- package/cap/bin/lib/cap-memory-pin.cjs +183 -0
- package/cap/bin/lib/cap-memory-platform.cjs +490 -0
- package/cap/bin/lib/cap-memory-prune.cjs +707 -0
- package/cap/bin/lib/cap-memory-schema.cjs +812 -0
- package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
- package/cap/bin/lib/cap-migrate.cjs +540 -0
- package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
- package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
- package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
- package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
- package/cap/bin/lib/cap-reconcile.cjs +570 -0
- package/cap/bin/lib/cap-research-gate.cjs +218 -0
- package/cap/bin/lib/cap-scope-filter.cjs +402 -0
- package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
- package/cap/bin/lib/cap-session-extract.cjs +987 -0
- package/cap/bin/lib/cap-session.cjs +445 -0
- package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
- package/cap/bin/lib/cap-stack-docs.cjs +646 -0
- package/cap/bin/lib/cap-tag-observer.cjs +371 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
- package/cap/bin/lib/cap-telemetry.cjs +466 -0
- package/cap/bin/lib/cap-test-audit.cjs +1438 -0
- package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
- package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
- package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
- package/cap/bin/lib/cap-trace.cjs +399 -0
- package/cap/bin/lib/cap-trust-mode.cjs +336 -0
- package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
- package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
- package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
- package/cap/bin/lib/cap-ui.cjs +1245 -0
- package/cap/bin/lib/cap-upgrade.cjs +1028 -0
- package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
- package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
- package/cap/bin/lib/cli/init-router.cjs +68 -0
- package/cap/bin/lib/cli/phase-router.cjs +102 -0
- package/cap/bin/lib/cli/state-router.cjs +61 -0
- package/cap/bin/lib/cli/template-router.cjs +37 -0
- package/cap/bin/lib/cli/uat-router.cjs +29 -0
- package/cap/bin/lib/cli/validation-router.cjs +26 -0
- package/cap/bin/lib/cli/verification-router.cjs +31 -0
- package/cap/bin/lib/cli/workstream-router.cjs +39 -0
- package/cap/bin/lib/commands.cjs +961 -0
- package/cap/bin/lib/config.cjs +467 -0
- package/cap/bin/lib/convention-reader.cjs +258 -0
- package/cap/bin/lib/core.cjs +1241 -0
- package/cap/bin/lib/feature-aggregator.cjs +423 -0
- package/cap/bin/lib/frontmatter.cjs +337 -0
- package/cap/bin/lib/init.cjs +1443 -0
- package/cap/bin/lib/manifest-generator.cjs +383 -0
- package/cap/bin/lib/milestone.cjs +253 -0
- package/cap/bin/lib/model-profiles.cjs +69 -0
- package/cap/bin/lib/monorepo-context.cjs +226 -0
- package/cap/bin/lib/monorepo-migrator.cjs +509 -0
- package/cap/bin/lib/phase.cjs +889 -0
- package/cap/bin/lib/profile-output.cjs +989 -0
- package/cap/bin/lib/profile-pipeline.cjs +540 -0
- package/cap/bin/lib/roadmap.cjs +330 -0
- package/cap/bin/lib/security.cjs +394 -0
- package/cap/bin/lib/session-manager.cjs +292 -0
- package/cap/bin/lib/skeleton-generator.cjs +179 -0
- package/cap/bin/lib/state.cjs +1032 -0
- package/cap/bin/lib/template.cjs +231 -0
- package/cap/bin/lib/test-detector.cjs +62 -0
- package/cap/bin/lib/uat.cjs +283 -0
- package/cap/bin/lib/verify.cjs +889 -0
- package/cap/bin/lib/workspace-detector.cjs +371 -0
- package/cap/bin/lib/workstream.cjs +492 -0
- package/cap/commands/gsd/workstreams.md +63 -0
- package/cap/references/arc-standard.md +315 -0
- package/cap/references/cap-agent-architecture.md +101 -0
- package/cap/references/cap-gitignore-template +9 -0
- package/cap/references/cap-zero-deps.md +158 -0
- package/cap/references/checkpoints.md +778 -0
- package/cap/references/continuation-format.md +249 -0
- package/cap/references/contract-test-templates.md +312 -0
- package/cap/references/feature-map-template.md +25 -0
- package/cap/references/git-integration.md +295 -0
- package/cap/references/git-planning-commit.md +38 -0
- package/cap/references/model-profiles.md +174 -0
- package/cap/references/phase-numbering.md +126 -0
- package/cap/references/planning-config.md +202 -0
- package/cap/references/property-test-templates.md +316 -0
- package/cap/references/security-test-templates.md +347 -0
- package/cap/references/session-template.json +8 -0
- package/cap/references/tdd.md +263 -0
- package/cap/references/user-profiling.md +681 -0
- package/cap/references/verification-patterns.md +612 -0
- package/cap/templates/UAT.md +265 -0
- package/cap/templates/claude-md.md +175 -0
- package/cap/templates/codebase/architecture.md +255 -0
- package/cap/templates/codebase/concerns.md +310 -0
- package/cap/templates/codebase/conventions.md +307 -0
- package/cap/templates/codebase/integrations.md +280 -0
- package/cap/templates/codebase/stack.md +186 -0
- package/cap/templates/codebase/structure.md +285 -0
- package/cap/templates/codebase/testing.md +480 -0
- package/cap/templates/config.json +44 -0
- package/cap/templates/context.md +352 -0
- package/cap/templates/continue-here.md +78 -0
- package/cap/templates/copilot-instructions.md +7 -0
- package/cap/templates/debug-subagent-prompt.md +91 -0
- package/cap/templates/discussion-log.md +63 -0
- package/cap/templates/milestone-archive.md +123 -0
- package/cap/templates/milestone.md +115 -0
- package/cap/templates/phase-prompt.md +610 -0
- package/cap/templates/planner-subagent-prompt.md +117 -0
- package/cap/templates/project.md +186 -0
- package/cap/templates/requirements.md +231 -0
- package/cap/templates/research-project/ARCHITECTURE.md +204 -0
- package/cap/templates/research-project/FEATURES.md +147 -0
- package/cap/templates/research-project/PITFALLS.md +200 -0
- package/cap/templates/research-project/STACK.md +120 -0
- package/cap/templates/research-project/SUMMARY.md +170 -0
- package/cap/templates/research.md +552 -0
- package/cap/templates/roadmap.md +202 -0
- package/cap/templates/state.md +176 -0
- package/cap/templates/summary.md +364 -0
- package/cap/templates/user-preferences.md +498 -0
- package/cap/templates/verification-report.md +322 -0
- package/cap/workflows/add-phase.md +112 -0
- package/cap/workflows/add-tests.md +351 -0
- package/cap/workflows/add-todo.md +158 -0
- package/cap/workflows/audit-milestone.md +340 -0
- package/cap/workflows/audit-uat.md +109 -0
- package/cap/workflows/autonomous.md +891 -0
- package/cap/workflows/check-todos.md +177 -0
- package/cap/workflows/cleanup.md +152 -0
- package/cap/workflows/complete-milestone.md +767 -0
- package/cap/workflows/diagnose-issues.md +231 -0
- package/cap/workflows/discovery-phase.md +289 -0
- package/cap/workflows/discuss-phase-assumptions.md +653 -0
- package/cap/workflows/discuss-phase.md +1049 -0
- package/cap/workflows/do.md +104 -0
- package/cap/workflows/execute-phase.md +846 -0
- package/cap/workflows/execute-plan.md +514 -0
- package/cap/workflows/fast.md +105 -0
- package/cap/workflows/forensics.md +265 -0
- package/cap/workflows/health.md +181 -0
- package/cap/workflows/help.md +660 -0
- package/cap/workflows/insert-phase.md +130 -0
- package/cap/workflows/list-phase-assumptions.md +178 -0
- package/cap/workflows/list-workspaces.md +56 -0
- package/cap/workflows/manager.md +362 -0
- package/cap/workflows/map-codebase.md +377 -0
- package/cap/workflows/milestone-summary.md +223 -0
- package/cap/workflows/new-milestone.md +486 -0
- package/cap/workflows/new-project.md +1250 -0
- package/cap/workflows/new-workspace.md +237 -0
- package/cap/workflows/next.md +97 -0
- package/cap/workflows/node-repair.md +92 -0
- package/cap/workflows/note.md +156 -0
- package/cap/workflows/pause-work.md +176 -0
- package/cap/workflows/plan-milestone-gaps.md +273 -0
- package/cap/workflows/plan-phase.md +857 -0
- package/cap/workflows/plant-seed.md +169 -0
- package/cap/workflows/pr-branch.md +129 -0
- package/cap/workflows/profile-user.md +449 -0
- package/cap/workflows/progress.md +507 -0
- package/cap/workflows/quick.md +757 -0
- package/cap/workflows/remove-phase.md +155 -0
- package/cap/workflows/remove-workspace.md +90 -0
- package/cap/workflows/research-phase.md +82 -0
- package/cap/workflows/resume-project.md +326 -0
- package/cap/workflows/review.md +228 -0
- package/cap/workflows/session-report.md +146 -0
- package/cap/workflows/settings.md +283 -0
- package/cap/workflows/ship.md +228 -0
- package/cap/workflows/stats.md +60 -0
- package/cap/workflows/transition.md +671 -0
- package/cap/workflows/ui-phase.md +298 -0
- package/cap/workflows/ui-review.md +161 -0
- package/cap/workflows/update.md +323 -0
- package/cap/workflows/validate-phase.md +170 -0
- package/cap/workflows/verify-phase.md +254 -0
- package/cap/workflows/verify-work.md +637 -0
- package/commands/cap/annotate.md +165 -0
- package/commands/cap/brainstorm.md +393 -0
- package/commands/cap/checkpoint.md +106 -0
- package/commands/cap/completeness.md +94 -0
- package/commands/cap/continue.md +72 -0
- package/commands/cap/debug.md +588 -0
- package/commands/cap/deps.md +169 -0
- package/commands/cap/design.md +479 -0
- package/commands/cap/init.md +354 -0
- package/commands/cap/iterate.md +249 -0
- package/commands/cap/learn.md +459 -0
- package/commands/cap/memory.md +275 -0
- package/commands/cap/migrate-feature-map.md +91 -0
- package/commands/cap/migrate-memory.md +108 -0
- package/commands/cap/migrate-tags.md +91 -0
- package/commands/cap/migrate.md +131 -0
- package/commands/cap/prototype.md +510 -0
- package/commands/cap/reconcile.md +121 -0
- package/commands/cap/review.md +360 -0
- package/commands/cap/save.md +72 -0
- package/commands/cap/scan.md +404 -0
- package/commands/cap/start.md +356 -0
- package/commands/cap/status.md +118 -0
- package/commands/cap/test-audit.md +262 -0
- package/commands/cap/test.md +394 -0
- package/commands/cap/trace.md +133 -0
- package/commands/cap/ui.md +167 -0
- package/hooks/dist/cap-check-update.js +115 -0
- package/hooks/dist/cap-context-monitor.js +185 -0
- package/hooks/dist/cap-learn-review-hook.js +114 -0
- package/hooks/dist/cap-learning-hook.js +192 -0
- package/hooks/dist/cap-memory.js +299 -0
- package/hooks/dist/cap-prompt-guard.js +97 -0
- package/hooks/dist/cap-statusline.js +157 -0
- package/hooks/dist/cap-tag-observer.js +115 -0
- package/hooks/dist/cap-version-check.js +112 -0
- package/hooks/dist/cap-workflow-guard.js +175 -0
- package/hooks/hooks.json +55 -0
- package/package.json +58 -0
- package/scripts/base64-scan.sh +262 -0
- package/scripts/build-hooks.js +93 -0
- package/scripts/cap-removal-checklist.md +202 -0
- package/scripts/prompt-injection-scan.sh +199 -0
- package/scripts/run-tests.cjs +181 -0
- package/scripts/secret-scan.sh +227 -0
|
@@ -0,0 +1,1943 @@
|
|
|
1
|
+
// @cap-context CAP v2.0 Feature Map reader/writer -- FEATURE-MAP.md is the single source of truth for all features, ACs, status, and dependencies.
|
|
2
|
+
// @cap-decision Markdown format for Feature Map (not JSON/YAML) -- human-readable, diffable in git, editable in any text editor. Machine-readable via regex parsing of structured table rows.
|
|
3
|
+
// @cap-decision Read and write are separate operations -- no in-memory mutation API. Read returns structured data, write takes structured data and serializes to markdown.
|
|
4
|
+
// @cap-constraint Zero external dependencies -- uses only Node.js built-ins (fs, path).
|
|
5
|
+
// @cap-pattern Feature Map is the bridge between all CAP workflows. Brainstorm writes entries, scan updates status, status reads for dashboard.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
// @cap-feature(feature:F-002) Feature Map Management — read/write/enrich FEATURE-MAP.md as single source of truth
|
|
10
|
+
// @cap-feature(feature:F-081) Multi-Format Feature Map Parser — Union ID regex (F-NNN | F-LONGFORM), bullet-style ACs, config-driven format selection
|
|
11
|
+
// @cap-feature(feature:F-082) Aggregate Feature Maps Across Monorepo Sub-Apps — readFeatureMap transparently merges sub-app maps via Rescoped Table or opt-in directory walk
|
|
12
|
+
|
|
13
|
+
// @cap-history(sessions:5, edits:23, since:2026-04-20, learned:2026-05-08) Frequently modified — 5 sessions, 23 edits
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const FEATURE_MAP_FILE = 'FEATURE-MAP.md';
|
|
18
|
+
|
|
19
|
+
// @cap-feature(feature:F-081) Union Feature-ID pattern: legacy F-NNN (3+ digits) OR long-form F-UPPERCASE
|
|
20
|
+
// @cap-feature(feature:F-089) Pattern widened to include mixed-case deskriptiv IDs (F-Hub-Spotlight-Carousel)
|
|
21
|
+
// for monorepo apps. Three-branch union; F-076-suffix invariant preserved (digit-leading suffixed
|
|
22
|
+
// forms match neither branch).
|
|
23
|
+
// @cap-decision(F-081/AC-1) The pattern is intentionally anchored on both ends; the letter branch
|
|
24
|
+
// requires letter-first char so digit-leading slugs like `F-076-suffix` continue to be REJECTED —
|
|
25
|
+
// preserves the F-076 schema invariant proven by cap-memory-schema tests.
|
|
26
|
+
// @cap-risk(reason:regex-asymmetry) The narrow header regex `featureHeaderRE` historically used `\d{3}`;
|
|
27
|
+
// widening it to the union must NOT also widen `getNextFeatureId`'s sequence detection (which only
|
|
28
|
+
// considers numeric IDs for next-id allocation). Long-form IDs are user-named and never auto-generated.
|
|
29
|
+
// @cap-decision(F-089/regex-sync) The canonical source is cap-feature-map-shard.cjs:FEATURE_ID_PATTERN.
|
|
30
|
+
// Keep this constant in sync with that file or callers will see asymmetric ID acceptance.
|
|
31
|
+
const FEATURE_ID_PATTERN = /^F-(?:\d{3,}|[A-Z](?:[A-Z0-9_]*[A-Z0-9])?(?:[-_][A-Z0-9_]*[A-Z0-9])*|[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+)$/;
|
|
32
|
+
|
|
33
|
+
// @cap-todo(ref:AC-9) Feature state lifecycle: planned -> prototyped -> tested -> shipped
|
|
34
|
+
const VALID_STATES = ['planned', 'prototyped', 'tested', 'shipped'];
|
|
35
|
+
const STATE_TRANSITIONS = {
|
|
36
|
+
planned: ['prototyped'],
|
|
37
|
+
prototyped: ['tested'],
|
|
38
|
+
tested: ['shipped'],
|
|
39
|
+
shipped: [],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} AcceptanceCriterion
|
|
44
|
+
* @property {string} id - AC identifier (e.g., "AC-1")
|
|
45
|
+
* @property {string} description - Imperative description text
|
|
46
|
+
* @property {'pending'|'implemented'|'tested'|'reviewed'} status - Current status
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} Feature
|
|
51
|
+
* @property {string} id - Feature ID (e.g., "F-001")
|
|
52
|
+
* @property {string} title - Feature title (verb+object format)
|
|
53
|
+
* @property {'planned'|'prototyped'|'tested'|'shipped'} state - Feature lifecycle state
|
|
54
|
+
* @property {AcceptanceCriterion[]} acs - Acceptance criteria
|
|
55
|
+
* @property {string[]} files - File references linked to this feature
|
|
56
|
+
* @property {string[]} dependencies - Feature IDs this depends on
|
|
57
|
+
* @property {string[]} usesDesign - F-063: DT-NNN / DC-NNN IDs that this feature references (default [])
|
|
58
|
+
* @property {Object<string,string>} metadata - Additional key-value metadata
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Object} FeatureMap
|
|
63
|
+
* @property {Feature[]} features - All features
|
|
64
|
+
* @property {string} lastScan - ISO timestamp of last scan
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
// @cap-todo(ref:AC-7) Feature Map is a single Markdown file at the project root named FEATURE-MAP.md
|
|
68
|
+
|
|
69
|
+
// @cap-todo(ref:AC-1) Generate empty FEATURE-MAP.md template with section headers (Features, Legend) and no feature entries
|
|
70
|
+
/**
|
|
71
|
+
* Generate the empty FEATURE-MAP.md template for /cap:init.
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
function generateTemplate() {
|
|
75
|
+
return `# Feature Map
|
|
76
|
+
|
|
77
|
+
> Single source of truth for feature identity, state, acceptance criteria, and relationships.
|
|
78
|
+
> Auto-enriched by \`@cap-feature\` tags and dependency analysis.
|
|
79
|
+
|
|
80
|
+
## Features
|
|
81
|
+
|
|
82
|
+
<!-- No features yet. Run /cap:brainstorm or add features with addFeature(). -->
|
|
83
|
+
|
|
84
|
+
## Legend
|
|
85
|
+
|
|
86
|
+
| State | Meaning |
|
|
87
|
+
|-------|---------|
|
|
88
|
+
| planned | Feature identified, not yet implemented |
|
|
89
|
+
| prototyped | Initial implementation exists |
|
|
90
|
+
| tested | Tests written and passing |
|
|
91
|
+
| shipped | Deployed / merged to main |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
*Last updated: ${new Date().toISOString()}*
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// @cap-api readFeatureMap(projectRoot, appPath) -- Reads and parses FEATURE-MAP.md from project root or app subdirectory.
|
|
99
|
+
// Returns: FeatureMap object with features and lastScan timestamp.
|
|
100
|
+
// @cap-todo(ref:AC-10) Feature Map is the single source of truth for feature identity, state, ACs, and relationships
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
103
|
+
* @param {string|null} [appPath=null] - Relative app path (e.g., "apps/flow"). If null, reads from projectRoot.
|
|
104
|
+
* @param {{ safe?: boolean }} [options] - F-081/iter1: when `safe:true`, duplicate-id detection
|
|
105
|
+
* returns `{features, lastScan, parseError}` instead of throwing. Default false (legacy throw
|
|
106
|
+
* preserved — pinned by adversarial regression test "duplicate-on-disk causes readFeatureMap
|
|
107
|
+
* to throw with positioned error").
|
|
108
|
+
* @returns {FeatureMap}
|
|
109
|
+
*/
|
|
110
|
+
function readFeatureMap(projectRoot, appPath, options) {
|
|
111
|
+
const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
|
|
112
|
+
const filePath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
113
|
+
|
|
114
|
+
// @cap-feature(feature:F-089, primary:true) Sharded-mode dispatch — load index + per-feature files
|
|
115
|
+
// when features/ directory exists. Falls back to monolithic for AC-7 backwards-compat.
|
|
116
|
+
if (_shard().isShardedMap(projectRoot, appPath)) {
|
|
117
|
+
return _readShardedMap(projectRoot, appPath, options);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!fs.existsSync(filePath)) {
|
|
121
|
+
return { features: [], lastScan: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
125
|
+
// @cap-todo(ac:F-081/AC-7) Forward projectRoot for config-driven format style.
|
|
126
|
+
// @cap-decision(F-081/iter1+iter2) Safe-mode opt-in: default strict (throw on duplicate) preserves
|
|
127
|
+
// pinned adversarial test; `{safe: true}` returns parseError for tooling. Write-back paths bail on
|
|
128
|
+
// parseError; read-only consumers warn-and-continue. F-076/F-077 lesson on user-controlled IDs in
|
|
129
|
+
// warn messages: parseError.message is wrapped in String(...).trim() at every call site.
|
|
130
|
+
const safe = Boolean(options && options.safe === true);
|
|
131
|
+
const rootResult = parseFeatureMapContent(content, { projectRoot, safe });
|
|
132
|
+
|
|
133
|
+
// @cap-todo(ac:F-082/AC-1) Aggregation only triggers on ROOT-level reads (appPath null/undef).
|
|
134
|
+
// Sub-app reads (caller passed appPath explicitly) get the single map verbatim — the caller
|
|
135
|
+
// is targeting one sub-app deliberately and aggregation would be surprising.
|
|
136
|
+
// @cap-decision(F-082/single-level-aggregation) Single-level only: root → sub-apps → features.
|
|
137
|
+
// A sub-app FEATURE-MAP.md with its own Rescoped Table is NOT recursively expanded — that
|
|
138
|
+
// would create cycles, bloat parser surface, and confuse the round-trip writer (which sub-app
|
|
139
|
+
// does a write-back belong to?). If a project legitimately needs nested workspaces, the user
|
|
140
|
+
// reads each sub-app explicitly via appPath.
|
|
141
|
+
if (appPath) return rootResult;
|
|
142
|
+
|
|
143
|
+
// @cap-todo(ac:F-082/AC-1) Detect "Rescoped Feature Maps" header in the root content; if found,
|
|
144
|
+
// parse the table to discover sub-app paths and aggregate transparently.
|
|
145
|
+
// @cap-todo(ac:F-083/AC-6) Lazy-require monorepo module — see _monorepo() definition.
|
|
146
|
+
const _mr = _monorepo();
|
|
147
|
+
const rescopedEntries = _mr.parseRescopedTable(content);
|
|
148
|
+
|
|
149
|
+
// @cap-todo(ac:F-082/AC-3) Opt-in directory-walk fallback: when no Rescoped Table is present
|
|
150
|
+
// AND `.cap/config.json:featureMaps.discover === "auto"`, glob `apps/*/FEATURE-MAP.md`
|
|
151
|
+
// and `packages/*/FEATURE-MAP.md`. Default `"table-only"` preserves legacy single-map behavior.
|
|
152
|
+
/** @type {Array<{appPath: string}>} */
|
|
153
|
+
let aggregationTargets = rescopedEntries;
|
|
154
|
+
if (aggregationTargets.length === 0) {
|
|
155
|
+
const cfg = readCapConfig(projectRoot);
|
|
156
|
+
const discoverMode =
|
|
157
|
+
cfg && cfg.featureMaps && typeof cfg.featureMaps.discover === 'string'
|
|
158
|
+
? cfg.featureMaps.discover
|
|
159
|
+
: 'table-only';
|
|
160
|
+
if (discoverMode === 'auto') {
|
|
161
|
+
aggregationTargets = _mr.discoverSubAppFeatureMaps(projectRoot);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (aggregationTargets.length === 0) return rootResult;
|
|
166
|
+
|
|
167
|
+
return _mr.aggregateSubAppFeatureMaps(projectRoot, rootResult, aggregationTargets, { safe });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// @cap-feature(feature:F-083) parseRescopedTable / discoverSubAppFeatureMaps /
|
|
171
|
+
// aggregateSubAppFeatureMaps extracted to cap-feature-map-monorepo.cjs; re-exported below.
|
|
172
|
+
|
|
173
|
+
// @cap-todo(ref:AC-8) Each feature entry contains: feature ID, title, state, ACs, and file references
|
|
174
|
+
// @cap-todo(ref:AC-14) Feature Map scales to 80-120 features in a single file
|
|
175
|
+
// @cap-feature(feature:F-041) Fix Feature Map Parser Roundtrip Symmetry — parser is the read half of a
|
|
176
|
+
// symmetric pair with serializeFeatureMap. Parser must accept every format the serializer can write,
|
|
177
|
+
// without dropping ACs or transforming status case beyond what the serializer can re-emit.
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @typedef {Object} CapConfig
|
|
181
|
+
* @property {('table'|'bullet'|'auto')=} featureMapStyle - explicit AC format selection (default "auto")
|
|
182
|
+
*/
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @typedef {Object} ParseOptions
|
|
186
|
+
* @property {string=} projectRoot - Absolute path to project root for config loading
|
|
187
|
+
* @property {('table'|'bullet'|'auto')=} featureMapStyle - explicit override (takes precedence over config)
|
|
188
|
+
* @property {boolean=} safe - F-081/iter1: when true, return `{features, lastScan, parseError}`
|
|
189
|
+
* on duplicate-feature-id detection instead of throwing. Default false (legacy throw behavior
|
|
190
|
+
* preserved for direct parseFeatureMapContent callers and existing tests). readFeatureMap
|
|
191
|
+
* passes safe:true by default so the 24 bare CLI/library call sites no longer crash on a
|
|
192
|
+
* hand-edited duplicate.
|
|
193
|
+
*/
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @typedef {Object} ParseError
|
|
197
|
+
* @property {string} code - Stable error code (currently only 'CAP_DUPLICATE_FEATURE_ID')
|
|
198
|
+
* @property {string} message - Human-readable error message
|
|
199
|
+
* @property {string} duplicateId - Normalized feature ID that collided
|
|
200
|
+
* @property {number} firstLine - Line number of the first occurrence (1-based)
|
|
201
|
+
* @property {number} duplicateLine - Line number of the duplicate occurrence (1-based)
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
// @cap-feature(feature:F-081) readCapConfig — graceful loader for .cap/config.json
|
|
205
|
+
// @cap-todo(ac:F-081/AC-7) Config-loader infrastructure available in cap-feature-map.cjs for F-082 reuse.
|
|
206
|
+
// @cap-decision(F-081/AC-7) Returns {} on every error path (missing file, malformed JSON, read errors).
|
|
207
|
+
// Rationale: parser must remain robust — config is an enhancement, never a hard dependency. Throwing
|
|
208
|
+
// here would make a malformed config file silently break every Feature-Map read across all CAP commands.
|
|
209
|
+
/**
|
|
210
|
+
* Read .cap/config.json from a project root with graceful defaults on every error path.
|
|
211
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
212
|
+
* @returns {CapConfig} - Parsed config, or empty object on missing/malformed/read-error
|
|
213
|
+
*/
|
|
214
|
+
function readCapConfig(projectRoot) {
|
|
215
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return {};
|
|
216
|
+
const configPath = path.join(projectRoot, '.cap', 'config.json');
|
|
217
|
+
if (!fs.existsSync(configPath)) return {};
|
|
218
|
+
try {
|
|
219
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
220
|
+
const parsed = JSON.parse(raw);
|
|
221
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
222
|
+
return parsed;
|
|
223
|
+
} catch (_e) {
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Parse FEATURE-MAP.md content into structured data.
|
|
230
|
+
* @param {string} content - Raw markdown content
|
|
231
|
+
* @param {ParseOptions=} options - Optional parser options (projectRoot for config, featureMapStyle override)
|
|
232
|
+
* @returns {FeatureMap}
|
|
233
|
+
*/
|
|
234
|
+
function parseFeatureMapContent(content, options) {
|
|
235
|
+
const features = [];
|
|
236
|
+
const lines = content.split('\n');
|
|
237
|
+
|
|
238
|
+
// @cap-todo(ac:F-081/AC-3) Resolve format style: explicit option > config > "auto" default.
|
|
239
|
+
let formatStyle = 'auto';
|
|
240
|
+
if (options && typeof options.featureMapStyle === 'string') {
|
|
241
|
+
formatStyle = options.featureMapStyle;
|
|
242
|
+
} else if (options && options.projectRoot) {
|
|
243
|
+
const cfg = readCapConfig(options.projectRoot);
|
|
244
|
+
if (cfg && typeof cfg.featureMapStyle === 'string') {
|
|
245
|
+
formatStyle = cfg.featureMapStyle;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (formatStyle !== 'table' && formatStyle !== 'bullet' && formatStyle !== 'auto') {
|
|
249
|
+
formatStyle = 'auto';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Match feature headers: ### F-001: Title text [state]
|
|
253
|
+
// Also accepts: ### F-001: Title text (no [state] — state comes from separate line)
|
|
254
|
+
// Also accepts em-dash / en-dash / hyphen separator with surrounding spaces:
|
|
255
|
+
// ### F-001 — Title ### F-001 – Title ### F-001 - Title
|
|
256
|
+
// @cap-todo(ac:F-081/AC-1) Union Feature-ID regex accepts F-NNN AND F-LONGFORM (uppercase-led).
|
|
257
|
+
// @cap-decision(F-082/iter2) Header separator tolerance: GoetzeInvest real-world dry-run uses ` — ` (em-dash)
|
|
258
|
+
// throughout root + sub-app maps. Accepting `:` plus dash forms (with required surrounding whitespace
|
|
259
|
+
// to disambiguate from hyphen-in-ID) makes CAP tolerant of the legacy CAP-init-template em-dash style
|
|
260
|
+
// without forcing migration. Tested in cap-feature-map-emdash.test.cjs.
|
|
261
|
+
// @cap-feature(feature:F-089) Header regex keeps in sync with FEATURE_ID_PATTERN above.
|
|
262
|
+
const featureHeaderRE = /^###\s+(F-(?:\d{3,}|[A-Z](?:[A-Z0-9_]*[A-Z0-9])?(?:[-_][A-Z0-9_]*[A-Z0-9])*|[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+))(?::\s+|\s+[—–-]\s+)(.+?)\s*$/;
|
|
263
|
+
// Match AC rows: | AC-N | status | description |
|
|
264
|
+
// End-anchor (\s*$) forces the non-greedy description group to expand up to the
|
|
265
|
+
// trailing pipe of the row, not the first internal pipe. Without the anchor an AC
|
|
266
|
+
// description containing a literal "|" character (e.g. "parse foo | bar from stdin")
|
|
267
|
+
// was silently truncated at the first pipe — which e.g. dropped F-057/AC-2 during
|
|
268
|
+
// the 2026-04-21 ECC feature batch and required a manual restore workaround.
|
|
269
|
+
const acRowRE = /^\|\s*(AC-\d+)\s*\|\s*(\w+)\s*\|\s*(.+?)\s*\|\s*$/;
|
|
270
|
+
// @cap-todo(ac:F-041/AC-4) Strict header detector: only match the literal table header
|
|
271
|
+
// "| AC | Status | Description |" so AC-N rows whose description contains the word "Status"
|
|
272
|
+
// (e.g. F-041/AC-6) are not misclassified as table headers, which previously truncated the
|
|
273
|
+
// table and silently dropped subsequent AC rows.
|
|
274
|
+
const acTableHeaderRE = /^\|\s*AC\s*\|\s*Status\s*\|\s*Description\s*\|/i;
|
|
275
|
+
// Match AC checkboxes: - [x] description or - [ ] description
|
|
276
|
+
const acCheckboxRE = /^[\s]*-\s+\[(x| )\]\s+(.+)/;
|
|
277
|
+
// @cap-todo(ac:F-081/AC-2) Bullet-style AC with EXPLICIT AC-N prefix.
|
|
278
|
+
// @cap-decision(F-081/AC-2) Prefix-bearing format `- [ ] AC-N: description` is the canonical bullet
|
|
279
|
+
// shape; this differs from the legacy `- **AC:**`-section anonymous checkboxes which auto-number.
|
|
280
|
+
// Explicit AC-IDs let bullet-style maps survive AC reordering / partial AC additions without
|
|
281
|
+
// silently re-numbering downstream entries — a pitfall observed in early CAP brainstorms.
|
|
282
|
+
// @cap-risk(reason:asterisk-bullet-marker) Markdown allows `*` and `-` as bullet markers; we accept
|
|
283
|
+
// both for parser robustness (some editors auto-rewrite). The serializer always emits `-` to keep
|
|
284
|
+
// roundtrip output stable.
|
|
285
|
+
// @cap-todo(ac:F-081/AC-2 iter:1) Description capture widened from `(.+?)` to `(.*?)` so an
|
|
286
|
+
// empty-description bullet (`- [ ] AC-1:` with EOL) is recognized as a legitimate AC instead
|
|
287
|
+
// of falling through to the legacy anonymous-checkbox branch (which would silently swallow
|
|
288
|
+
// the AC-N: prefix as the description and block all subsequent bullets via inAcCheckboxes=true).
|
|
289
|
+
// @cap-decision(F-081/iter1) Stage-2 #1 fix: empty-desc bullet is a legitimate parse outcome —
|
|
290
|
+
// downstream code should treat `description: ''` as missing-text, never as missing-AC.
|
|
291
|
+
const bulletAcRE = /^[\s]*[-*]\s+\[([ x])\]\s+(AC-\d+):\s*(.*?)\s*$/i;
|
|
292
|
+
// @cap-decision(F-081/iter1) Shape-only detector is SEPARATE from the value-extraction regex.
|
|
293
|
+
// The shape detector matches the prefix `- [ ] AC-N:` regardless of description content (empty
|
|
294
|
+
// or non-empty) so `isExplicitBulletShape` (below) gates the legacy branch correctly even when
|
|
295
|
+
// the value-extraction regex would have matched anyway. Keeping the two regexes separate also
|
|
296
|
+
// means future loosening of the value regex (e.g. multi-line continuation) cannot accidentally
|
|
297
|
+
// re-introduce the silent-swallow bug fixed here.
|
|
298
|
+
const bulletAcShapeRE = /^[\s]*[-*]\s+\[[ xX]\]\s+AC-\d+:/;
|
|
299
|
+
// Match file refs: - `path/to/file`
|
|
300
|
+
const fileRefRE = /^-\s+`(.+?)`/;
|
|
301
|
+
// Match dependencies: **Depends on:** F-001, F-002 or - **Dependencies:** F-001
|
|
302
|
+
const depsRE = /^-?\s*\*\*Depend(?:s on|encies):\*\*\s*(.+)/;
|
|
303
|
+
// @cap-todo(ac:F-063/AC-3) Match design usage: **Uses design:** DT-001, DC-001
|
|
304
|
+
// @cap-decision(F-063/D3) Line format mirrors **Depends on:** — same shape, same delimiter, same position.
|
|
305
|
+
const usesDesignRE = /^-?\s*\*\*Uses design:\*\*\s*(.+)/i;
|
|
306
|
+
// Match status line: - **Status:** shipped or **Status:** shipped
|
|
307
|
+
const statusLineRE = /^-?\s*\*\*Status:\*\*\s*(\w+)/;
|
|
308
|
+
// File refs detected inline via regex test (not a stored RE)
|
|
309
|
+
// Match AC section header: - **AC:**
|
|
310
|
+
const acSectionRE = /^-?\s*\*\*AC:\*\*/;
|
|
311
|
+
// Match lastScan in footer
|
|
312
|
+
const lastScanRE = /^\*Last updated:\s*(.+?)\*$/;
|
|
313
|
+
|
|
314
|
+
let currentFeature = null;
|
|
315
|
+
let inAcTable = false;
|
|
316
|
+
let inAcCheckboxes = false;
|
|
317
|
+
let inFileRefs = false;
|
|
318
|
+
let acCounter = 0;
|
|
319
|
+
let lastScan = null;
|
|
320
|
+
// @cap-todo(ac:F-081/AC-4) Track per-feature header line for positioned duplicate-error messages.
|
|
321
|
+
/** @type {Array<{id: string, line: number}>} */
|
|
322
|
+
const featureLineOrigins = [];
|
|
323
|
+
// @cap-todo(ac:F-081/AC-2) Track table-row presence per feature; "auto" only enables bullet-AC
|
|
324
|
+
// detection when zero table rows have been seen — matches the AC-2 contract and keeps the
|
|
325
|
+
// table fast-path for AC-6 unchanged.
|
|
326
|
+
let sawTableRow = false;
|
|
327
|
+
|
|
328
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
329
|
+
const line = lines[lineIdx];
|
|
330
|
+
const headerMatch = line.match(featureHeaderRE);
|
|
331
|
+
if (headerMatch) {
|
|
332
|
+
if (currentFeature) features.push(currentFeature);
|
|
333
|
+
// Extract [state] from end of title if present, otherwise state is null (set from status line)
|
|
334
|
+
let title = headerMatch[2];
|
|
335
|
+
let state = null;
|
|
336
|
+
const stateInTitle = title.match(/^(.+?)\s+\[(\w+)\]\s*$/);
|
|
337
|
+
if (stateInTitle) {
|
|
338
|
+
title = stateInTitle[1];
|
|
339
|
+
state = stateInTitle[2];
|
|
340
|
+
}
|
|
341
|
+
currentFeature = {
|
|
342
|
+
id: headerMatch[1],
|
|
343
|
+
title,
|
|
344
|
+
state: state || 'planned',
|
|
345
|
+
acs: [],
|
|
346
|
+
files: [],
|
|
347
|
+
dependencies: [],
|
|
348
|
+
usesDesign: [], // @cap-todo(ac:F-063/AC-3) F-063: default-empty DT/DC IDs list.
|
|
349
|
+
metadata: {},
|
|
350
|
+
};
|
|
351
|
+
featureLineOrigins.push({ id: headerMatch[1], line: lineIdx + 1 });
|
|
352
|
+
inAcTable = false;
|
|
353
|
+
inAcCheckboxes = false;
|
|
354
|
+
inFileRefs = false;
|
|
355
|
+
acCounter = 0;
|
|
356
|
+
sawTableRow = false;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!currentFeature) {
|
|
361
|
+
const scanMatch = line.match(lastScanRE);
|
|
362
|
+
if (scanMatch) lastScan = scanMatch[1].trim();
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Status line: - **Status:** shipped
|
|
367
|
+
// @cap-todo(ac:F-041/AC-3) Preserve case of status as written so a roundtrip
|
|
368
|
+
// (parse -> serialize -> parse) does not transform the value. Canonical
|
|
369
|
+
// lifecycle values are lowercase; this only matters for non-canonical inputs.
|
|
370
|
+
const statusMatch = line.match(statusLineRE);
|
|
371
|
+
if (statusMatch) {
|
|
372
|
+
currentFeature.state = statusMatch[1];
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Detect AC table start using the strict header detector (see acTableHeaderRE above).
|
|
377
|
+
// @cap-todo(ac:F-041/AC-4) Use strict header regex instead of substring "Status" check
|
|
378
|
+
// so AC-N data rows whose description contains the word "Status" do not falsely trigger
|
|
379
|
+
// a "new table" reset that drops subsequent AC entries.
|
|
380
|
+
if (acTableHeaderRE.test(line)) {
|
|
381
|
+
inAcTable = true;
|
|
382
|
+
inAcCheckboxes = false;
|
|
383
|
+
inFileRefs = false;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// Skip table separator
|
|
387
|
+
if (line.match(/^\|[\s-]+\|/)) continue;
|
|
388
|
+
|
|
389
|
+
const acMatch = line.match(acRowRE);
|
|
390
|
+
if (acMatch && inAcTable) {
|
|
391
|
+
// @cap-todo(ac:F-041/AC-3) Preserve case of AC status so roundtrip is lossless.
|
|
392
|
+
currentFeature.acs.push({
|
|
393
|
+
id: acMatch[1],
|
|
394
|
+
description: acMatch[3].trim(),
|
|
395
|
+
status: acMatch[2],
|
|
396
|
+
});
|
|
397
|
+
sawTableRow = true; // @cap-todo(ac:F-081/AC-2) Block bullet detection once any table row exists.
|
|
398
|
+
// @cap-todo(ac:F-081/iter1) Mark this feature's AC origin format as 'table' so the
|
|
399
|
+
// serializer can preserve it on round-trip. Once any table row is seen, the feature
|
|
400
|
+
// sticks to 'table' even if a stray bullet appears later (matches sawTableRow gate).
|
|
401
|
+
currentFeature._inputFormat = 'table';
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// AC section header: - **AC:**
|
|
406
|
+
if (line.match(acSectionRE)) {
|
|
407
|
+
inAcCheckboxes = true;
|
|
408
|
+
inAcTable = false;
|
|
409
|
+
inFileRefs = false;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// @cap-todo(ac:F-081/AC-2) Bullet-style AC detection — must precede the legacy anonymous-checkbox
|
|
414
|
+
// branch because the legacy branch's regex is broader and would swallow `AC-N:` prefixes verbatim
|
|
415
|
+
// into the description, breaking AC-ID round-trips.
|
|
416
|
+
// @cap-decision(F-081/AC-3) Format-style gate:
|
|
417
|
+
// - "table" : never run bullet branch (caller declared table-only)
|
|
418
|
+
// - "bullet" : always run bullet branch when no `- **AC:**` section is active
|
|
419
|
+
// - "auto" : only run bullet branch when no table rows have been seen for this feature yet
|
|
420
|
+
const bulletAcMatch = line.match(bulletAcRE);
|
|
421
|
+
if (
|
|
422
|
+
bulletAcMatch &&
|
|
423
|
+
formatStyle !== 'table' &&
|
|
424
|
+
!inAcCheckboxes &&
|
|
425
|
+
!inFileRefs &&
|
|
426
|
+
(formatStyle === 'bullet' || (formatStyle === 'auto' && !sawTableRow))
|
|
427
|
+
) {
|
|
428
|
+
const checked = bulletAcMatch[1].toLowerCase() === 'x';
|
|
429
|
+
currentFeature.acs.push({
|
|
430
|
+
id: bulletAcMatch[2],
|
|
431
|
+
description: bulletAcMatch[3].trim(),
|
|
432
|
+
status: checked ? 'tested' : 'pending',
|
|
433
|
+
});
|
|
434
|
+
inAcTable = false;
|
|
435
|
+
inFileRefs = false;
|
|
436
|
+
// @cap-todo(ac:F-081/iter1) Mark this feature's AC origin format as 'bullet' so the
|
|
437
|
+
// serializer preserves bullet format on the next write — fixes Stage-2 #2 (round-trip
|
|
438
|
+
// asymmetry) where every writeFeatureMap call after readFeatureMap silently rewrote
|
|
439
|
+
// bullet input to table form.
|
|
440
|
+
// @cap-decision(F-081/iter1) `_inputFormat` is in-memory metadata (underscore prefix
|
|
441
|
+
// marks it as runtime-only, never persisted as a separate front-matter field).
|
|
442
|
+
// Source-of-truth on subsequent reads is the AC line shape itself; this field is a
|
|
443
|
+
// hint for the serializer between read and write within the same process. Mirrors
|
|
444
|
+
// the F-082 `metadata.subApp` runtime-hint pattern.
|
|
445
|
+
// @cap-risk(reason:proto-pollution) `_inputFormat` is set from parser branch detection,
|
|
446
|
+
// never from raw user input. A malicious FEATURE-MAP cannot inject this field through
|
|
447
|
+
// parsed content (no attacker-controlled key path reaches here).
|
|
448
|
+
currentFeature._inputFormat = 'bullet';
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// AC checkboxes: - [x] description or - [ ] description
|
|
453
|
+
// @cap-decision(F-081/AC-2) Lines that match the explicit `AC-N:` bullet shape are NEVER routed
|
|
454
|
+
// through the legacy anonymous-checkbox branch — even if the bullet branch above declined them
|
|
455
|
+
// (e.g. format="table" or table rows already seen). Anonymous auto-numbering of `AC-N:`-prefixed
|
|
456
|
+
// text would silently rewrite the AC ID to a counter and dump the prefix into the description,
|
|
457
|
+
// which is exactly the silent-corruption mode AC-2/AC-4 are written to prevent.
|
|
458
|
+
// @cap-todo(ac:F-081/AC-2 iter:1) Use shape-only detector here (independent of the value
|
|
459
|
+
// regex's description capture) so empty-description bullets `- [ ] AC-1:` are also gated
|
|
460
|
+
// away from the legacy branch. Without this, the legacy branch would set inAcCheckboxes=true
|
|
461
|
+
// and block all subsequent bullets in the same feature.
|
|
462
|
+
const isExplicitBulletShape = bulletAcShapeRE.test(line);
|
|
463
|
+
const checkboxMatch = isExplicitBulletShape ? null : line.match(acCheckboxRE);
|
|
464
|
+
if (checkboxMatch && (inAcCheckboxes || !inFileRefs)) {
|
|
465
|
+
acCounter++;
|
|
466
|
+
const checked = checkboxMatch[1] === 'x';
|
|
467
|
+
currentFeature.acs.push({
|
|
468
|
+
id: `AC-${acCounter}`,
|
|
469
|
+
description: checkboxMatch[2].trim(),
|
|
470
|
+
status: checked ? 'tested' : 'pending',
|
|
471
|
+
});
|
|
472
|
+
inAcCheckboxes = true;
|
|
473
|
+
inAcTable = false;
|
|
474
|
+
inFileRefs = false;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// File references — inline on **Files:** line or as separate section
|
|
479
|
+
// Matches: **Files:** or - **Files:** `path`, `path2`
|
|
480
|
+
if (/^-?\s*\*\*Files:\*\*/.test(line)) {
|
|
481
|
+
// Extract any backtick-quoted paths on this same line
|
|
482
|
+
const pathMatches = line.matchAll(/`([^`]+)`/g);
|
|
483
|
+
for (const m of pathMatches) {
|
|
484
|
+
currentFeature.files.push(m[1]);
|
|
485
|
+
}
|
|
486
|
+
inFileRefs = true;
|
|
487
|
+
inAcTable = false;
|
|
488
|
+
inAcCheckboxes = false;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (inFileRefs) {
|
|
493
|
+
const refMatch = line.match(fileRefRE);
|
|
494
|
+
if (refMatch) {
|
|
495
|
+
currentFeature.files.push(refMatch[1]);
|
|
496
|
+
continue;
|
|
497
|
+
} else if (line.trim() === '') {
|
|
498
|
+
inFileRefs = false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Dependencies
|
|
503
|
+
const depsMatch = line.match(depsRE);
|
|
504
|
+
if (depsMatch) {
|
|
505
|
+
currentFeature.dependencies = depsMatch[1].split(',').map(d => d.trim()).filter(Boolean);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// @cap-todo(ac:F-063/AC-3) Parse **Uses design:** line — DT/DC IDs comma-separated.
|
|
510
|
+
// Tolerant parser: accepts "DT-001", "DT-001 primary-color" (takes the ID prefix only).
|
|
511
|
+
const usesMatch = line.match(usesDesignRE);
|
|
512
|
+
if (usesMatch) {
|
|
513
|
+
currentFeature.usesDesign = usesMatch[1]
|
|
514
|
+
.split(',')
|
|
515
|
+
.map(s => s.trim())
|
|
516
|
+
.filter(Boolean)
|
|
517
|
+
.map(s => {
|
|
518
|
+
// Accept "DT-001" or "DT-001 primary-color" — keep only the ID token.
|
|
519
|
+
const m = s.match(/^(DT-\d{3,}|DC-\d{3,})\b/);
|
|
520
|
+
return m ? m[1] : s;
|
|
521
|
+
})
|
|
522
|
+
.filter(s => /^(DT-\d{3,}|DC-\d{3,})$/.test(s));
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const scanMatch = line.match(lastScanRE);
|
|
527
|
+
if (scanMatch) lastScan = scanMatch[1].trim();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (currentFeature) features.push(currentFeature);
|
|
531
|
+
|
|
532
|
+
// @cap-todo(ac:F-081/AC-4) Duplicate-after-normalization detection — HARD error, no silent dedup.
|
|
533
|
+
// @cap-decision(F-081/AC-4) Throws synchronously rather than returning a soft result. Rationale:
|
|
534
|
+
// silent dedup is exactly the failure mode the AC was written to prevent — a user who rename-collides
|
|
535
|
+
// two features (e.g. typoed `F-DEPLOY` vs `F-deploy`) would have one half their map disappear with
|
|
536
|
+
// no signal. Throwing forces visibility. The error message includes both line numbers so the user
|
|
537
|
+
// can navigate directly to the conflict in their editor.
|
|
538
|
+
// @cap-decision(F-081/iter1) Stage-2 #3 fix: opt-in safe mode. When `options.safe === true`, attach
|
|
539
|
+
// the structured error to `result.parseError` and return the partial map (features parsed up to
|
|
540
|
+
// the first duplicate). Default behavior (no `safe` flag, or explicit `safe:false`) preserves
|
|
541
|
+
// the throw — required by 18 existing duplicate-detection regression tests in cap-feature-map-bullet
|
|
542
|
+
// and cap-feature-map-adversarial, and by tooling that wants hard-fail semantics.
|
|
543
|
+
// @cap-risk(reason:partial-map-on-error) In safe mode the caller receives the features parsed up
|
|
544
|
+
// to (but not including) the duplicate header. This matches the "fail-fast at first collision"
|
|
545
|
+
// semantics of the throw path and gives downstream tooling a useful (if incomplete) view. CLI
|
|
546
|
+
// surfaces should always check `result.parseError` and surface a warning when present.
|
|
547
|
+
const safe = Boolean(options && options.safe === true);
|
|
548
|
+
const seenIds = new Map();
|
|
549
|
+
let parseError;
|
|
550
|
+
for (const origin of featureLineOrigins) {
|
|
551
|
+
const normalized = String(origin.id).toUpperCase().trim();
|
|
552
|
+
if (seenIds.has(normalized)) {
|
|
553
|
+
const firstLine = seenIds.get(normalized);
|
|
554
|
+
const message = `Duplicate feature ID after normalization: ${origin.id} (line ${origin.line}) collides with ${origin.id} (line ${firstLine})`;
|
|
555
|
+
if (safe) {
|
|
556
|
+
parseError = {
|
|
557
|
+
code: 'CAP_DUPLICATE_FEATURE_ID',
|
|
558
|
+
message,
|
|
559
|
+
duplicateId: normalized,
|
|
560
|
+
firstLine,
|
|
561
|
+
duplicateLine: origin.line,
|
|
562
|
+
};
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
const err = new Error(message);
|
|
566
|
+
err.code = 'CAP_DUPLICATE_FEATURE_ID';
|
|
567
|
+
err.duplicateId = normalized;
|
|
568
|
+
err.firstLine = firstLine;
|
|
569
|
+
err.duplicateLine = origin.line;
|
|
570
|
+
throw err;
|
|
571
|
+
}
|
|
572
|
+
seenIds.set(normalized, origin.line);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// @cap-todo(ac:F-081/iter1) parseError is only present when set — keeps the result shape minimal
|
|
576
|
+
// for the happy path (zero new property on the 99.9% case).
|
|
577
|
+
if (parseError) {
|
|
578
|
+
return { features, lastScan, parseError };
|
|
579
|
+
}
|
|
580
|
+
return { features, lastScan };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// @cap-feature(feature:F-088, primary:true) Surgical-patch helpers — flip status bits directly
|
|
584
|
+
// in the on-disk FEATURE-MAP content via regex substitution, without going through the lossy
|
|
585
|
+
// parse → serialize round-trip. Used by setAcStatus and updateFeatureState (AC-5).
|
|
586
|
+
//
|
|
587
|
+
// @cap-decision(F-088/AC-5) The surgical patcher operates on the canonical bracket-header form
|
|
588
|
+
// (`### F-NNN: Title [state]`) and table-form ACs (`| AC-N | status | desc |`). Bullet-form
|
|
589
|
+
// ACs and legacy `- **Status:** state` headers are NOT yet supported by the patcher; callers
|
|
590
|
+
// fall back to the parse → write path for those, which is acceptable because (a) the bracket
|
|
591
|
+
// form + table form is what 99% of real maps use, (b) bullet/legacy maps tend to be smaller
|
|
592
|
+
// and less likely to have prose lost on round-trip.
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Surgically update the [state] bracket in a feature header line.
|
|
596
|
+
* Returns the new content + a hit flag. If the regex did not match (header in legacy form
|
|
597
|
+
* or feature missing), returns hit=false and the original content unchanged so the caller
|
|
598
|
+
* can fall back to a parse → write flow.
|
|
599
|
+
*
|
|
600
|
+
* @param {string} content
|
|
601
|
+
* @param {string} featureId e.g. "F-001"
|
|
602
|
+
* @param {string} newState
|
|
603
|
+
* @returns {{ content: string, hit: boolean }}
|
|
604
|
+
*/
|
|
605
|
+
function _surgicalUpdateFeatureState(content, featureId, newState) {
|
|
606
|
+
// Match: `### F-NNN: Title text [state]` OR `### F-NNN — Title text [state]`
|
|
607
|
+
// Capture the prefix (up to the [) and the closing ] so we can replace just the state token.
|
|
608
|
+
const escapedId = featureId.replace(/[-]/g, '\\-');
|
|
609
|
+
const re = new RegExp(
|
|
610
|
+
'^(###\\s+' + escapedId + '(?::\\s+|\\s+[—–-]\\s+)[^\\n]*?\\[)([^\\]]+)(\\][ \\t]*$)',
|
|
611
|
+
'm'
|
|
612
|
+
);
|
|
613
|
+
if (!re.test(content)) return { content, hit: false };
|
|
614
|
+
return {
|
|
615
|
+
content: content.replace(re, (_m, before, _state, after) => before + newState + after),
|
|
616
|
+
hit: true,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Surgically update the status cell of an AC table row inside a specific feature block.
|
|
622
|
+
* Scopes the substitution to the lines between the feature's header and the next feature
|
|
623
|
+
* header (or end-of-file) so two features that share an AC-id (e.g. both have AC-1) don't
|
|
624
|
+
* collide.
|
|
625
|
+
*
|
|
626
|
+
* @param {string} content
|
|
627
|
+
* @param {string} featureId
|
|
628
|
+
* @param {string} acId e.g. "AC-1"
|
|
629
|
+
* @param {string} newStatus
|
|
630
|
+
* @returns {{ content: string, hit: boolean }}
|
|
631
|
+
*/
|
|
632
|
+
function _surgicalSetAcStatus(content, featureId, acId, newStatus) {
|
|
633
|
+
const escapedId = featureId.replace(/[-]/g, '\\-');
|
|
634
|
+
const headerRe = new RegExp(
|
|
635
|
+
'^###\\s+' + escapedId + '(?::\\s+|\\s+[—–-]\\s+)',
|
|
636
|
+
'm'
|
|
637
|
+
);
|
|
638
|
+
const headerMatch = headerRe.exec(content);
|
|
639
|
+
if (!headerMatch) return { content, hit: false };
|
|
640
|
+
|
|
641
|
+
// Find the start of the NEXT feature header (or end of content).
|
|
642
|
+
const blockStart = headerMatch.index;
|
|
643
|
+
const afterHeader = content.slice(blockStart + headerMatch[0].length);
|
|
644
|
+
// @cap-feature(feature:F-089) Next-header regex keeps in sync with FEATURE_ID_PATTERN above.
|
|
645
|
+
const nextHeaderMatch = /^###\s+F-(?:\d{3,}|[A-Z](?:[A-Z0-9_]*[A-Z0-9])?(?:[-_][A-Z0-9_]*[A-Z0-9])*|[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+)(?::\s+|\s+[—–-]\s+)/m.exec(afterHeader);
|
|
646
|
+
const blockEnd = nextHeaderMatch
|
|
647
|
+
? blockStart + headerMatch[0].length + nextHeaderMatch.index
|
|
648
|
+
: content.length;
|
|
649
|
+
|
|
650
|
+
const block = content.slice(blockStart, blockEnd);
|
|
651
|
+
const escapedAc = acId.replace(/[-]/g, '\\-');
|
|
652
|
+
// Match: `| AC-N | oldStatus | description |`
|
|
653
|
+
// Capture the AC cell + leading pipe, then the status token, then the trailing pipe + desc.
|
|
654
|
+
const acRowRe = new RegExp(
|
|
655
|
+
'^(\\|\\s*' + escapedAc + '\\s*\\|\\s*)(\\w+)(\\s*\\|)',
|
|
656
|
+
'm'
|
|
657
|
+
);
|
|
658
|
+
if (!acRowRe.test(block)) return { content, hit: false };
|
|
659
|
+
|
|
660
|
+
const newBlock = block.replace(acRowRe, (_m, prefix, _status, suffix) => prefix + newStatus + suffix);
|
|
661
|
+
return {
|
|
662
|
+
content: content.slice(0, blockStart) + newBlock + content.slice(blockEnd),
|
|
663
|
+
hit: true,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Surgically update the on-disk FEATURE-MAP file by applying multiple state/AC-status patches
|
|
669
|
+
* in a single read-modify-write cycle. Atomic via .tmp + rename. Bypasses the F-088 shrink
|
|
670
|
+
* guard because we never re-serialize through the lossy path.
|
|
671
|
+
*
|
|
672
|
+
* @param {string} projectRoot
|
|
673
|
+
* @param {string|null|undefined} appPath
|
|
674
|
+
* @param {Array<
|
|
675
|
+
* {kind:'state', featureId:string, newState:string} |
|
|
676
|
+
* {kind:'ac', featureId:string, acId:string, newStatus:string}
|
|
677
|
+
* >} patches
|
|
678
|
+
* @returns {{ ok: boolean, hits: number, misses: Array<object> }}
|
|
679
|
+
*/
|
|
680
|
+
function applySurgicalPatches(projectRoot, appPath, patches) {
|
|
681
|
+
// @cap-feature(feature:F-089) Sharded-mode dispatch — route to per-feature surgical patcher.
|
|
682
|
+
if (_shard().isShardedMap(projectRoot, appPath)) {
|
|
683
|
+
return _applyShardedSurgicalPatches(projectRoot, appPath, patches);
|
|
684
|
+
}
|
|
685
|
+
const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
|
|
686
|
+
const filePath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
687
|
+
let content;
|
|
688
|
+
try {
|
|
689
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
690
|
+
} catch (e) {
|
|
691
|
+
return { ok: false, hits: 0, misses: patches.slice(), error: e.message };
|
|
692
|
+
}
|
|
693
|
+
let hits = 0;
|
|
694
|
+
const misses = [];
|
|
695
|
+
for (const p of patches) {
|
|
696
|
+
let result;
|
|
697
|
+
if (p.kind === 'state') {
|
|
698
|
+
result = _surgicalUpdateFeatureState(content, p.featureId, p.newState);
|
|
699
|
+
} else if (p.kind === 'ac') {
|
|
700
|
+
result = _surgicalSetAcStatus(content, p.featureId, p.acId, p.newStatus);
|
|
701
|
+
} else {
|
|
702
|
+
misses.push(p);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (result.hit) {
|
|
706
|
+
content = result.content;
|
|
707
|
+
hits++;
|
|
708
|
+
} else {
|
|
709
|
+
misses.push(p);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (hits === 0) {
|
|
713
|
+
return { ok: false, hits: 0, misses };
|
|
714
|
+
}
|
|
715
|
+
// Atomic write: tmp + rename
|
|
716
|
+
const tmp = filePath + '.tmp';
|
|
717
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
718
|
+
fs.renameSync(tmp, filePath);
|
|
719
|
+
return { ok: true, hits, misses };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// @cap-api writeFeatureMap(projectRoot, featureMap, appPath, options) -- Serializes FeatureMap to FEATURE-MAP.md.
|
|
723
|
+
// Side effect: overwrites FEATURE-MAP.md at project root or app subdirectory.
|
|
724
|
+
/**
|
|
725
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
726
|
+
* @param {FeatureMap} featureMap - Structured feature map data
|
|
727
|
+
* @param {string|null} [appPath=null] - Relative app path (e.g., "apps/flow"). If null, writes to projectRoot.
|
|
728
|
+
* @param {{ legacyStatusLine?: boolean, allowShrink?: boolean }} [options] - Serialization options forwarded to serializeFeatureMap.
|
|
729
|
+
*/
|
|
730
|
+
function writeFeatureMap(projectRoot, featureMap, appPath, options) {
|
|
731
|
+
// @cap-feature(feature:F-089) Sharded-mode dispatch — route to per-feature writer.
|
|
732
|
+
if (_shard().isShardedMap(projectRoot, appPath)) {
|
|
733
|
+
return _writeShardedMap(projectRoot, featureMap, appPath, options);
|
|
734
|
+
}
|
|
735
|
+
const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
|
|
736
|
+
const filePath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
737
|
+
|
|
738
|
+
// @cap-todo(ac:F-082/AC-8) Round-trip idempotency: preserve the on-disk Rescoped Table on root writes.
|
|
739
|
+
// @cap-decision(F-082/AC-8 strategy-a) Filter feature list to ROOT-only (no metadata.subApp) before
|
|
740
|
+
// serializing; re-inject the Rescoped Table verbatim after serialize. Sub-app mutations require
|
|
741
|
+
// explicit appPath. Without this, aggregated read → write would flatten sub-apps into root.
|
|
742
|
+
let preservedRescopedBlock = null;
|
|
743
|
+
/** @type {Feature[]} */
|
|
744
|
+
let featuresForRoot = featureMap && Array.isArray(featureMap.features) ? featureMap.features : [];
|
|
745
|
+
// @cap-todo(ac:F-082/iter1 warn:7) Warning #7 fix: when the on-disk file vanishes between
|
|
746
|
+
// existsSync and readFileSync (TOCTOU race), abort the write rather than silently flattening.
|
|
747
|
+
// Returning false signals the caller. Pre-iter1 silently fell through and clobbered the
|
|
748
|
+
// Rescoped Table on disk if the file briefly disappeared.
|
|
749
|
+
// @cap-decision(F-082/iter1 warn:7) Hard abort over best-effort write — the alternative is to
|
|
750
|
+
// write a flattened map that destroys the Rescoped Table mid-race. Aborting preserves data
|
|
751
|
+
// integrity at the cost of a single retry.
|
|
752
|
+
let toctouAbort = false;
|
|
753
|
+
if (!appPath && fs.existsSync(filePath)) {
|
|
754
|
+
try {
|
|
755
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
756
|
+
// @cap-todo(ac:F-083/AC-6) Lazy-require — see _monorepo() definition.
|
|
757
|
+
preservedRescopedBlock = _monorepo().extractRescopedBlock(existing);
|
|
758
|
+
} catch (e) {
|
|
759
|
+
// File existed at existsSync but disappeared / unreadable on read → abort.
|
|
760
|
+
console.warn('cap: writeFeatureMap aborted — Rescoped Table preservation failed (TOCTOU): ' + String(e && e.message ? e.message : e).trim());
|
|
761
|
+
toctouAbort = true;
|
|
762
|
+
}
|
|
763
|
+
if (toctouAbort) return false;
|
|
764
|
+
if (preservedRescopedBlock) {
|
|
765
|
+
// Filter out aggregated sub-app features from the root write — they belong to their
|
|
766
|
+
// own FEATURE-MAP.md files and were merged in only at read-time.
|
|
767
|
+
// @cap-todo(ac:F-082/iter1 fix:1) Safety-net: if any sub-app features survived to here,
|
|
768
|
+
// warn loudly. With Fix #1 (monorepo-aware enrichFromTags) this branch should ideally
|
|
769
|
+
// never trigger — but it's a defense-in-depth signal for code paths that bypass the
|
|
770
|
+
// monorepo-aware enrichment helpers.
|
|
771
|
+
const droppedSubApps = new Set();
|
|
772
|
+
let droppedCount = 0;
|
|
773
|
+
for (const f of featuresForRoot) {
|
|
774
|
+
if (f && f.metadata && f.metadata.subApp) {
|
|
775
|
+
droppedSubApps.add(f.metadata.subApp);
|
|
776
|
+
droppedCount++;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (droppedCount > 0) {
|
|
780
|
+
console.warn(
|
|
781
|
+
'cap: writeFeatureMap dropped ' + droppedCount + ' sub-app feature(s) (subApps: ' +
|
|
782
|
+
[...droppedSubApps].sort().join(', ') + '). ' +
|
|
783
|
+
'Use writeFeatureMap(root, ..., appPath) or call mutation functions per sub-app to persist sub-app changes.'
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
featuresForRoot = featuresForRoot.filter(f => !(f && f.metadata && f.metadata.subApp));
|
|
787
|
+
}
|
|
788
|
+
} else if (appPath) {
|
|
789
|
+
// @cap-decision(F-082/iter1 warn:6) Symmetric filter for sub-app writes: drop foreign-subApp and
|
|
790
|
+
// root-direct features. Only warn when filter changed input AND features remain (distinguishes
|
|
791
|
+
// misuse from the legitimate single-map case where features have NO metadata.subApp). Legacy
|
|
792
|
+
// contract preserved: readFeatureMap(root, appPath) returning a no-metadata single-map still works.
|
|
793
|
+
const ownSubApp = path.basename(appPath);
|
|
794
|
+
const featuresInScope = [];
|
|
795
|
+
let droppedForeign = 0;
|
|
796
|
+
for (const f of featuresForRoot) {
|
|
797
|
+
const subApp = f && f.metadata && f.metadata.subApp;
|
|
798
|
+
if (!subApp) { droppedForeign++; continue; }
|
|
799
|
+
if (subApp !== ownSubApp) { droppedForeign++; continue; }
|
|
800
|
+
featuresInScope.push(f);
|
|
801
|
+
}
|
|
802
|
+
if (droppedForeign > 0 && featuresInScope.length > 0) {
|
|
803
|
+
console.warn(
|
|
804
|
+
'cap: writeFeatureMap (appPath=' + appPath + ') dropped ' + droppedForeign +
|
|
805
|
+
' feature(s) that did not belong to this sub-app.'
|
|
806
|
+
);
|
|
807
|
+
featuresForRoot = featuresInScope;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const filteredMap = { ...featureMap, features: featuresForRoot };
|
|
812
|
+
let content = serializeFeatureMap(filteredMap, options);
|
|
813
|
+
|
|
814
|
+
if (preservedRescopedBlock) {
|
|
815
|
+
// @cap-todo(ac:F-083/AC-6) Lazy-require — see _monorepo() definition.
|
|
816
|
+
content = _monorepo().injectRescopedBlock(content, preservedRescopedBlock);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// @cap-todo(ac:F-088/AC-7) Pre-write safety net: refuse to write a FEATURE-MAP that lost
|
|
820
|
+
// more than 50% of its lines vs. the on-disk version. The lossy round-trip in this module
|
|
821
|
+
// silently drops free-text descriptions, **Group:** markers, --- separators, and other
|
|
822
|
+
// unstructured content (real-world: GoetzeInvest hub shrunk 3303 → 1902 lines on a single
|
|
823
|
+
// reconcile run). Until the lossless round-trip lands (AC-1..4), this guard turns silent
|
|
824
|
+
// data loss into a loud, actionable error. The 50% threshold + 50-line floor lets small
|
|
825
|
+
// maps shrink legitimately (e.g. 10-feature project removing half via cleanup) while
|
|
826
|
+
// catching the 3303 → 20 stub-wipe failure mode the memory pitfall warns about.
|
|
827
|
+
if (!options || options.allowShrink !== true) {
|
|
828
|
+
let preExisting = null;
|
|
829
|
+
try {
|
|
830
|
+
preExisting = fs.readFileSync(filePath, 'utf8');
|
|
831
|
+
} catch (_e) {
|
|
832
|
+
preExisting = null; // first write; nothing to compare against
|
|
833
|
+
}
|
|
834
|
+
if (preExisting && preExisting.length > 0) {
|
|
835
|
+
const oldLines = preExisting.split('\n').length;
|
|
836
|
+
const newLines = content.split('\n').length;
|
|
837
|
+
const SAFETY_MIN_LINES = 50; // sanity floor — small maps may legitimately halve
|
|
838
|
+
const SAFETY_RATIO = 0.5;
|
|
839
|
+
if (oldLines >= SAFETY_MIN_LINES && newLines < oldLines * SAFETY_RATIO) {
|
|
840
|
+
const err = new Error(
|
|
841
|
+
'cap: writeFeatureMap aborted — output is ' + newLines + ' lines, on-disk is ' +
|
|
842
|
+
oldLines + ' (lost ' + (oldLines - newLines) + ' lines, ' +
|
|
843
|
+
Math.round(((oldLines - newLines) / oldLines) * 100) + '% shrink). This is almost ' +
|
|
844
|
+
'always a lossy round-trip bug (F-088). Pass options.allowShrink:true to override after ' +
|
|
845
|
+
'verifying the diff.'
|
|
846
|
+
);
|
|
847
|
+
err.code = 'CAP_FEATURE_MAP_SHRINK_GUARD';
|
|
848
|
+
err.oldLines = oldLines;
|
|
849
|
+
err.newLines = newLines;
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// @cap-feature(feature:F-089) Lazy accessor for cap-feature-map-shard.cjs. Mirrors the F-083
|
|
860
|
+
// _monorepo() pattern. Used inside function bodies (never at top-level) so the shard module
|
|
861
|
+
// stays out of the require cycle and the import order remains stable.
|
|
862
|
+
let _shardCache = null;
|
|
863
|
+
function _shard() {
|
|
864
|
+
if (!_shardCache) _shardCache = require('./cap-feature-map-shard.cjs');
|
|
865
|
+
return _shardCache;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Read a sharded Feature Map: parse FEATURE-MAP.md (index) for the entry list, then load each
|
|
870
|
+
* features/<id>.md and parse it as a single-feature mini-map. Returns the same FeatureMap shape
|
|
871
|
+
* as monolithic readFeatureMap so all downstream code is shape-compatible.
|
|
872
|
+
*
|
|
873
|
+
* @param {string} projectRoot
|
|
874
|
+
* @param {string|null|undefined} appPath
|
|
875
|
+
* @param {{ safe?: boolean }=} options
|
|
876
|
+
* @returns {FeatureMap}
|
|
877
|
+
*/
|
|
878
|
+
function _readShardedMap(projectRoot, appPath, options) {
|
|
879
|
+
const sh = _shard();
|
|
880
|
+
const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
|
|
881
|
+
const indexPath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
882
|
+
const safe = Boolean(options && options.safe === true);
|
|
883
|
+
|
|
884
|
+
let indexContent = '';
|
|
885
|
+
let lastScan = null;
|
|
886
|
+
if (fs.existsSync(indexPath)) {
|
|
887
|
+
indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
888
|
+
const m = indexContent.match(/^\*Last updated:\s*(.+?)\*$/m);
|
|
889
|
+
if (m) lastScan = m[1].trim();
|
|
890
|
+
}
|
|
891
|
+
const indexEntries = sh.parseIndex(indexContent);
|
|
892
|
+
|
|
893
|
+
/** @type {Feature[]} */
|
|
894
|
+
const features = [];
|
|
895
|
+
for (const entry of indexEntries) {
|
|
896
|
+
const filePath = sh.featureFilePath(projectRoot, entry.id, appPath);
|
|
897
|
+
if (!fs.existsSync(filePath)) {
|
|
898
|
+
// Index lists a feature file that doesn't exist — surface a structured warning but continue
|
|
899
|
+
// so partial/recovering states remain readable.
|
|
900
|
+
const msg = 'cap: feature file missing for index entry ' + entry.id + ' (expected ' + filePath + ')';
|
|
901
|
+
if (safe) {
|
|
902
|
+
console.warn(msg);
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
console.warn(msg);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
let blockContent;
|
|
909
|
+
try {
|
|
910
|
+
blockContent = fs.readFileSync(filePath, 'utf8');
|
|
911
|
+
} catch (e) {
|
|
912
|
+
console.warn('cap: failed to read feature file ' + filePath + ': ' + _safeForError(e && e.message));
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
const parsed = parseFeatureMapContent(blockContent, { projectRoot, safe });
|
|
916
|
+
if (parsed.parseError) {
|
|
917
|
+
// Per-feature parse error — skip this feature, surface warning. Sharded model isolates
|
|
918
|
+
// damage to a single feature instead of poisoning the whole map.
|
|
919
|
+
console.warn('cap: parseError in ' + filePath + ': ' + _safeForError(parsed.parseError.message));
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
if (parsed.features.length === 0) {
|
|
923
|
+
console.warn('cap: feature file produced no parsed feature: ' + filePath);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
// Trust the per-feature file content; the index entry is just a summary cache.
|
|
927
|
+
const feature = parsed.features[0];
|
|
928
|
+
// If the index says a different state from the per-feature file, the per-feature file wins
|
|
929
|
+
// (it's the authoritative source). The index is best-effort summary.
|
|
930
|
+
features.push(feature);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return { features, lastScan };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Write a sharded Feature Map: per-feature file for each feature + atomic index update.
|
|
938
|
+
* Used by the writeFeatureMap dispatcher when the project is in sharded mode.
|
|
939
|
+
*
|
|
940
|
+
* @param {string} projectRoot
|
|
941
|
+
* @param {FeatureMap} featureMap
|
|
942
|
+
* @param {string|null|undefined} appPath
|
|
943
|
+
* @param {{ legacyStatusLine?: boolean, allowShrink?: boolean }=} options
|
|
944
|
+
* @returns {boolean}
|
|
945
|
+
*/
|
|
946
|
+
function _writeShardedMap(projectRoot, featureMap, appPath, options) {
|
|
947
|
+
const sh = _shard();
|
|
948
|
+
const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
|
|
949
|
+
const indexPath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
950
|
+
const featuresDir = sh.featuresDirPath(projectRoot, appPath);
|
|
951
|
+
|
|
952
|
+
fs.mkdirSync(featuresDir, { recursive: true });
|
|
953
|
+
|
|
954
|
+
const features = (featureMap && Array.isArray(featureMap.features)) ? featureMap.features : [];
|
|
955
|
+
/** @type {import('./cap-feature-map-shard.cjs').IndexEntry[]} */
|
|
956
|
+
const indexEntries = [];
|
|
957
|
+
|
|
958
|
+
for (const feature of features) {
|
|
959
|
+
if (!feature || !sh.validateFeatureId(feature.id)) continue;
|
|
960
|
+
const target = sh.featureFilePath(projectRoot, feature.id, appPath);
|
|
961
|
+
// Per-feature serialization: emit only THIS feature's block (header + Depends + ACs + Files).
|
|
962
|
+
// We construct a single-feature mini-FeatureMap and reuse the existing serializer, then strip
|
|
963
|
+
// the global header/Legend/footer so only the feature block remains.
|
|
964
|
+
const singleMap = {
|
|
965
|
+
features: [feature],
|
|
966
|
+
lastScan: null,
|
|
967
|
+
};
|
|
968
|
+
const fullSerialized = serializeFeatureMap(singleMap, options);
|
|
969
|
+
// Extract just the feature block: from `### F-` to the next `## ` (Legend) or to start of footer.
|
|
970
|
+
const blockMatch = /(^### F-[\s\S]+?)(\n## Legend|\n---\n\*Last updated:)/m.exec(fullSerialized);
|
|
971
|
+
const blockContent = blockMatch ? blockMatch[1].replace(/\s+$/, '') + '\n' : fullSerialized;
|
|
972
|
+
// Atomic write
|
|
973
|
+
const tmp = target + '.tmp';
|
|
974
|
+
fs.writeFileSync(tmp, blockContent, 'utf8');
|
|
975
|
+
fs.renameSync(tmp, target);
|
|
976
|
+
indexEntries.push({ id: feature.id, state: feature.state, title: feature.title });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Re-build index. We do NOT preserve any prose between feature entries — the sharded mode
|
|
980
|
+
// moves prose into the per-feature files, so the index is always clean header + entries + Legend.
|
|
981
|
+
const indexContent = sh.serializeIndex(indexEntries);
|
|
982
|
+
const tmpIdx = indexPath + '.tmp';
|
|
983
|
+
fs.writeFileSync(tmpIdx, indexContent, 'utf8');
|
|
984
|
+
fs.renameSync(tmpIdx, indexPath);
|
|
985
|
+
return true;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Apply F-088-style surgical patches in sharded mode. Routes each patch by featureId to the
|
|
990
|
+
* relevant per-feature file. State changes also surgical-patch the index entry.
|
|
991
|
+
*
|
|
992
|
+
* @param {string} projectRoot
|
|
993
|
+
* @param {string|null|undefined} appPath
|
|
994
|
+
* @param {Array<
|
|
995
|
+
* {kind:'state', featureId:string, newState:string} |
|
|
996
|
+
* {kind:'ac', featureId:string, acId:string, newStatus:string}
|
|
997
|
+
* >} patches
|
|
998
|
+
* @returns {{ ok: boolean, hits: number, misses: Array<object> }}
|
|
999
|
+
*/
|
|
1000
|
+
function _applyShardedSurgicalPatches(projectRoot, appPath, patches) {
|
|
1001
|
+
const sh = _shard();
|
|
1002
|
+
const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
|
|
1003
|
+
const indexPath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
1004
|
+
|
|
1005
|
+
// Group patches by featureId so we apply all changes for one file in a single read-modify-write.
|
|
1006
|
+
/** @type {Map<string, Array<object>>} */
|
|
1007
|
+
const byFeature = new Map();
|
|
1008
|
+
for (const p of patches) {
|
|
1009
|
+
if (!p || !p.featureId || !sh.validateFeatureId(p.featureId)) continue;
|
|
1010
|
+
if (!byFeature.has(p.featureId)) byFeature.set(p.featureId, []);
|
|
1011
|
+
byFeature.get(p.featureId).push(p);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
let hits = 0;
|
|
1015
|
+
const misses = [];
|
|
1016
|
+
|
|
1017
|
+
// Per-feature file patches.
|
|
1018
|
+
for (const [featureId, featurePatches] of byFeature.entries()) {
|
|
1019
|
+
const filePath = sh.featureFilePath(projectRoot, featureId, appPath);
|
|
1020
|
+
let content;
|
|
1021
|
+
try {
|
|
1022
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
1023
|
+
} catch (_e) {
|
|
1024
|
+
// Feature file doesn't exist — record as miss for every patch.
|
|
1025
|
+
for (const p of featurePatches) misses.push(p);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
let modified = false;
|
|
1029
|
+
for (const p of featurePatches) {
|
|
1030
|
+
let result;
|
|
1031
|
+
if (p.kind === 'state') {
|
|
1032
|
+
result = _surgicalUpdateFeatureState(content, p.featureId, p.newState);
|
|
1033
|
+
} else if (p.kind === 'ac') {
|
|
1034
|
+
result = _surgicalSetAcStatus(content, p.featureId, p.acId, p.newStatus);
|
|
1035
|
+
} else {
|
|
1036
|
+
misses.push(p);
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (result.hit) {
|
|
1040
|
+
content = result.content;
|
|
1041
|
+
modified = true;
|
|
1042
|
+
hits++;
|
|
1043
|
+
} else {
|
|
1044
|
+
misses.push(p);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (modified) {
|
|
1048
|
+
const tmp = filePath + '.tmp';
|
|
1049
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
1050
|
+
fs.renameSync(tmp, filePath);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Index state-column updates: for every successful 'state' patch, surgical-patch the index too.
|
|
1055
|
+
if (fs.existsSync(indexPath)) {
|
|
1056
|
+
let indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
1057
|
+
let indexChanged = false;
|
|
1058
|
+
for (const [featureId, featurePatches] of byFeature.entries()) {
|
|
1059
|
+
const stateP = featurePatches.find(p => p.kind === 'state');
|
|
1060
|
+
if (!stateP) continue;
|
|
1061
|
+
const r = sh._updateIndexEntry(indexContent, featureId, { state: stateP.newState });
|
|
1062
|
+
if (r.hit) {
|
|
1063
|
+
indexContent = r.content;
|
|
1064
|
+
indexChanged = true;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (indexChanged) {
|
|
1068
|
+
const tmp = indexPath + '.tmp';
|
|
1069
|
+
fs.writeFileSync(tmp, indexContent, 'utf8');
|
|
1070
|
+
fs.renameSync(tmp, indexPath);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (hits === 0) {
|
|
1075
|
+
return { ok: false, hits: 0, misses };
|
|
1076
|
+
}
|
|
1077
|
+
return { ok: true, hits, misses };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// @cap-feature(feature:F-082) _safeForError — sanitize a user-controlled value before
|
|
1081
|
+
// interpolating it into console.warn/error. Strips non-printable bytes (incl. ANSI CSI, BEL,
|
|
1082
|
+
// BS, NUL) and caps length at 256. F-076/F-077/F-081 doctrine.
|
|
1083
|
+
// @cap-decision(F-083/balance) Stays in core: used by writeFeatureMap (TOCTOU warn) AND by the
|
|
1084
|
+
// monorepo enrichment helpers via lazy-require — hosting it in core keeps the cycle accessor
|
|
1085
|
+
// list shorter and avoids an inverse import edge.
|
|
1086
|
+
/**
|
|
1087
|
+
* @param {*} value - any user-controlled value to be interpolated into a warn message
|
|
1088
|
+
* @param {number} [maxLen=256] - max output length before truncation
|
|
1089
|
+
* @returns {string}
|
|
1090
|
+
*/
|
|
1091
|
+
function _safeForError(value, maxLen = 256) {
|
|
1092
|
+
let s;
|
|
1093
|
+
try {
|
|
1094
|
+
s = String(value);
|
|
1095
|
+
} catch (_e) {
|
|
1096
|
+
s = '<unprintable>';
|
|
1097
|
+
}
|
|
1098
|
+
// Strip any non-printable byte (incl. ESC, BEL, BS, NUL). Keep printable ASCII + multibyte UTF-8
|
|
1099
|
+
// (codepoints >= 0x20). This neutralizes ANSI CSI sequences regardless of how they're wrapped.
|
|
1100
|
+
// eslint-disable-next-line no-control-regex
|
|
1101
|
+
s = s.replace(/[\x00-\x1f\x7f]/g, '');
|
|
1102
|
+
s = s.trim();
|
|
1103
|
+
if (s.length > maxLen) s = s.slice(0, maxLen) + '…';
|
|
1104
|
+
return s;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// @cap-feature(feature:F-083) Lazy accessor for cap-feature-map-monorepo.cjs. Used INSIDE
|
|
1108
|
+
// function bodies (never at top-level) to break the cycle between core and monorepo.
|
|
1109
|
+
// The monorepo module's mirror is `_core()` in cap-feature-map-monorepo.cjs.
|
|
1110
|
+
// @cap-decision(F-083/cycle) Lazy-require both directions; AC-6 static-analysis test pins
|
|
1111
|
+
// the no-cycle contract.
|
|
1112
|
+
let _monorepoCache = null;
|
|
1113
|
+
function _monorepo() {
|
|
1114
|
+
if (!_monorepoCache) _monorepoCache = require('./cap-feature-map-monorepo.cjs');
|
|
1115
|
+
return _monorepoCache;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// @cap-feature(feature:F-041) Serializer is the write half of the symmetric pair.
|
|
1119
|
+
// It must preserve every status value the parser accepted (AC-1) and offer a legacy
|
|
1120
|
+
// **Status:** line emission mode (AC-6) so the legacy non-table input format is not
|
|
1121
|
+
// forcibly upgraded to bracketed-header format on the first roundtrip.
|
|
1122
|
+
// @cap-feature(feature:F-081) Bullet/table-aware serializer — preserves the AC format
|
|
1123
|
+
// the parser saw on the way in (Stage-2 #2 fix).
|
|
1124
|
+
/**
|
|
1125
|
+
* Serialize FeatureMap to markdown string.
|
|
1126
|
+
* @param {FeatureMap} featureMap
|
|
1127
|
+
* @param {{ legacyStatusLine?: boolean, featureMapStyle?: ('table'|'bullet') }} [options]
|
|
1128
|
+
* - legacyStatusLine: when true, emit `### F-NNN: Title` followed by `- **Status:** state`
|
|
1129
|
+
* instead of `### F-NNN: Title [state]`. Default false (canonical bracket-header form).
|
|
1130
|
+
* - featureMapStyle: F-081/iter1 — global override for AC format. Resolution order:
|
|
1131
|
+
* per-feature `_inputFormat` (set by parser) > options.featureMapStyle > 'table' default.
|
|
1132
|
+
* @returns {string}
|
|
1133
|
+
*/
|
|
1134
|
+
function serializeFeatureMap(featureMap, options = {}) {
|
|
1135
|
+
// @cap-todo(ac:F-041/AC-6) Optional legacy emission keeps non-table input shape stable.
|
|
1136
|
+
const legacyStatusLine = Boolean(options && options.legacyStatusLine);
|
|
1137
|
+
// @cap-todo(ac:F-081/iter1) Resolve global format style from options. Default 'table' preserves
|
|
1138
|
+
// pre-iter1 behavior — features without _inputFormat (e.g. created via addFeature on a fresh
|
|
1139
|
+
// project) keep emitting tables unless the project explicitly opts into bullets via the option.
|
|
1140
|
+
const globalStyle =
|
|
1141
|
+
options && (options.featureMapStyle === 'bullet' || options.featureMapStyle === 'table')
|
|
1142
|
+
? options.featureMapStyle
|
|
1143
|
+
: null;
|
|
1144
|
+
const lines = [
|
|
1145
|
+
'# Feature Map',
|
|
1146
|
+
'',
|
|
1147
|
+
'> Single source of truth for feature identity, state, acceptance criteria, and relationships.',
|
|
1148
|
+
'> Auto-enriched by `@cap-feature` tags and dependency analysis.',
|
|
1149
|
+
'',
|
|
1150
|
+
'## Features',
|
|
1151
|
+
'',
|
|
1152
|
+
];
|
|
1153
|
+
|
|
1154
|
+
for (const feature of featureMap.features) {
|
|
1155
|
+
// @cap-todo(ac:F-041/AC-1) feature.state is emitted verbatim — no case mutation,
|
|
1156
|
+
// so any value the parser accepted survives the roundtrip unchanged.
|
|
1157
|
+
if (legacyStatusLine) {
|
|
1158
|
+
lines.push(`### ${feature.id}: ${feature.title}`);
|
|
1159
|
+
lines.push('');
|
|
1160
|
+
lines.push(`- **Status:** ${feature.state}`);
|
|
1161
|
+
} else {
|
|
1162
|
+
lines.push(`### ${feature.id}: ${feature.title} [${feature.state}]`);
|
|
1163
|
+
}
|
|
1164
|
+
lines.push('');
|
|
1165
|
+
|
|
1166
|
+
if (feature.dependencies.length > 0) {
|
|
1167
|
+
lines.push(`**Depends on:** ${feature.dependencies.join(', ')}`);
|
|
1168
|
+
lines.push('');
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// @cap-todo(ac:F-063/AC-3) Serialize **Uses design:** only when non-empty — additive, backward-compatible.
|
|
1172
|
+
// Unset / empty arrays emit nothing so existing F-062-era FEATURE-MAP.md files roundtrip byte-identical.
|
|
1173
|
+
if (Array.isArray(feature.usesDesign) && feature.usesDesign.length > 0) {
|
|
1174
|
+
lines.push(`**Uses design:** ${feature.usesDesign.join(', ')}`);
|
|
1175
|
+
lines.push('');
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (feature.acs.length > 0) {
|
|
1179
|
+
// @cap-todo(ac:F-081/iter1) Per-feature format resolution: feature._inputFormat (from parser)
|
|
1180
|
+
// > options.featureMapStyle (caller override) > 'table' (legacy default).
|
|
1181
|
+
// @cap-decision(F-081/iter1) Per-feature wins over global option: if a single mixed-format
|
|
1182
|
+
// FEATURE-MAP.md has some bullet features and some table features (e.g. mid-migration),
|
|
1183
|
+
// round-tripping must preserve each one independently.
|
|
1184
|
+
const featureStyle =
|
|
1185
|
+
feature && feature._inputFormat === 'bullet'
|
|
1186
|
+
? 'bullet'
|
|
1187
|
+
: feature && feature._inputFormat === 'table'
|
|
1188
|
+
? 'table'
|
|
1189
|
+
: globalStyle || 'table';
|
|
1190
|
+
|
|
1191
|
+
if (featureStyle === 'bullet') {
|
|
1192
|
+
// @cap-todo(ac:F-081/iter1) Bullet emission: `- [x] AC-N: description` for tested,
|
|
1193
|
+
// `- [ ] AC-N: description` otherwise. Mirrors the canonical bullet shape the parser
|
|
1194
|
+
// accepts at line ~217 (bulletAcRE).
|
|
1195
|
+
// @cap-risk(reason:status-bullet-mapping) Bullet form has only two checkbox states
|
|
1196
|
+
// ([ ] / [x]) but the AC schema has 4 statuses (pending/prototyped/tested/implemented).
|
|
1197
|
+
// We map: tested -> [x]; everything else -> [ ]. This is lossy: a 'prototyped' or
|
|
1198
|
+
// 'implemented' AC round-trips as 'pending' through bullet-only storage. The intermediate
|
|
1199
|
+
// states are runtime/transitional in canonical CAP usage, so the loss is acceptable for
|
|
1200
|
+
// now. If this becomes user-visible, switch the bullet emitter to honor a `[?]` token
|
|
1201
|
+
// for 'prototyped' or fall back to table form on mixed-status features.
|
|
1202
|
+
// @cap-todo(ref:future-feature) Stage-2 #8 follow-up: enrichFromScan writes 'implemented'
|
|
1203
|
+
// status which has no faithful bullet representation. Defer to a follow-up feature that
|
|
1204
|
+
// defines a richer bullet token set or a hybrid emission policy.
|
|
1205
|
+
for (const ac of feature.acs) {
|
|
1206
|
+
const checked = ac.status === 'tested' ? 'x' : ' ';
|
|
1207
|
+
// Empty descriptions emit no trailing space — matches the parser's empty-desc shape.
|
|
1208
|
+
const desc = ac.description ? ` ${ac.description}` : '';
|
|
1209
|
+
lines.push(`- [${checked}] ${ac.id}:${desc}`);
|
|
1210
|
+
}
|
|
1211
|
+
lines.push('');
|
|
1212
|
+
} else {
|
|
1213
|
+
lines.push('| AC | Status | Description |');
|
|
1214
|
+
lines.push('|----|--------|-------------|');
|
|
1215
|
+
for (const ac of feature.acs) {
|
|
1216
|
+
// @cap-todo(ac:F-041/AC-1) ac.status emitted verbatim for lossless roundtrip.
|
|
1217
|
+
lines.push(`| ${ac.id} | ${ac.status} | ${ac.description} |`);
|
|
1218
|
+
}
|
|
1219
|
+
lines.push('');
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (feature.files.length > 0) {
|
|
1224
|
+
lines.push('**Files:**');
|
|
1225
|
+
for (const file of feature.files) {
|
|
1226
|
+
lines.push(`- \`${file}\``);
|
|
1227
|
+
}
|
|
1228
|
+
lines.push('');
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (featureMap.features.length === 0) {
|
|
1233
|
+
lines.push('<!-- No features yet. Run /cap:brainstorm or add features with addFeature(). -->');
|
|
1234
|
+
lines.push('');
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
lines.push('## Legend');
|
|
1238
|
+
lines.push('');
|
|
1239
|
+
lines.push('| State | Meaning |');
|
|
1240
|
+
lines.push('|-------|---------|');
|
|
1241
|
+
lines.push('| planned | Feature identified, not yet implemented |');
|
|
1242
|
+
lines.push('| prototyped | Initial implementation exists |');
|
|
1243
|
+
lines.push('| tested | Tests written and passing |');
|
|
1244
|
+
lines.push('| shipped | Deployed / merged to main |');
|
|
1245
|
+
lines.push('');
|
|
1246
|
+
lines.push('---');
|
|
1247
|
+
lines.push(`*Last updated: ${new Date().toISOString()}*`);
|
|
1248
|
+
lines.push('');
|
|
1249
|
+
|
|
1250
|
+
return lines.join('\n');
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// @cap-api addFeature(projectRoot, feature, appPath) -- Add a new feature entry to FEATURE-MAP.md.
|
|
1254
|
+
// @cap-decision(F-082/asymmetry) addFeature does NOT auto-redirect to a sub-app via
|
|
1255
|
+
// _maybeRedirectToSubApp, unlike updateFeatureState/setAcStatus/setFeatureUsesDesign.
|
|
1256
|
+
// Reasoning: a NEW feature has no metadata.subApp yet, so there is nothing to redirect
|
|
1257
|
+
// FROM. Sub-app placement is determined at write-time by the caller passing `appPath`
|
|
1258
|
+
// explicitly. This asymmetry is INTENTIONAL — do not "fix" it without first considering
|
|
1259
|
+
// where new features should land in a monorepo (currently always the scope named by
|
|
1260
|
+
// `appPath`, defaulting to root; opt-in to a sub-app via explicit `appPath`).
|
|
1261
|
+
/**
|
|
1262
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1263
|
+
* @param {{ title: string, acs?: AcceptanceCriterion[], dependencies?: string[], metadata?: Object }} feature - Feature data (ID auto-generated)
|
|
1264
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
1265
|
+
* @returns {Feature} - The added feature with generated ID
|
|
1266
|
+
*/
|
|
1267
|
+
function addFeature(projectRoot, feature, appPath) {
|
|
1268
|
+
// @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
|
|
1269
|
+
// @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
|
|
1270
|
+
const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
|
|
1271
|
+
if (featureMap.parseError) {
|
|
1272
|
+
console.warn('cap: addFeature aborted — duplicate feature ID detected: ' + String(featureMap.parseError.message).trim());
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
const id = getNextFeatureId(featureMap.features);
|
|
1276
|
+
// @cap-todo(ac:F-081/iter1) Inherit dominant AC format from existing features so a bullet-style
|
|
1277
|
+
// FEATURE-MAP.md does not get a stray table-style entry on addFeature. If existing features
|
|
1278
|
+
// are mostly bullets, new feature defaults to bullets. Pure-table or empty maps keep table.
|
|
1279
|
+
// @cap-decision(F-081/iter1) Use simple majority on existing features. Ties break toward
|
|
1280
|
+
// 'table' (the legacy default). Empty maps return 'table' (no signal to flip the default).
|
|
1281
|
+
let inheritedFormat = 'table';
|
|
1282
|
+
let bulletCount = 0;
|
|
1283
|
+
let tableCount = 0;
|
|
1284
|
+
for (const f of featureMap.features) {
|
|
1285
|
+
if (f._inputFormat === 'bullet') bulletCount++;
|
|
1286
|
+
else if (f._inputFormat === 'table') tableCount++;
|
|
1287
|
+
}
|
|
1288
|
+
if (bulletCount > tableCount) inheritedFormat = 'bullet';
|
|
1289
|
+
const newFeature = {
|
|
1290
|
+
id,
|
|
1291
|
+
title: feature.title,
|
|
1292
|
+
state: 'planned',
|
|
1293
|
+
acs: feature.acs || [],
|
|
1294
|
+
files: [],
|
|
1295
|
+
dependencies: feature.dependencies || [],
|
|
1296
|
+
usesDesign: feature.usesDesign || [], // F-063: default-empty DT/DC IDs list.
|
|
1297
|
+
metadata: feature.metadata || {},
|
|
1298
|
+
_inputFormat: inheritedFormat,
|
|
1299
|
+
};
|
|
1300
|
+
featureMap.features.push(newFeature);
|
|
1301
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
1302
|
+
return newFeature;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// @cap-feature(feature:F-042) Propagate Feature State Transitions to Acceptance Criteria —
|
|
1306
|
+
// extends updateFeatureState with AC propagation and a shipped-gate so feature/AC status cannot drift.
|
|
1307
|
+
// @cap-decision(feature:F-042) Canonical AC status set for setAcStatus / propagation is
|
|
1308
|
+
// pending | prototyped | tested. Legacy 'implemented' / 'reviewed' values that the parser may have
|
|
1309
|
+
// read from older Feature Maps are tolerated on read but never written by this module.
|
|
1310
|
+
const AC_VALID_STATUSES = ['pending', 'prototyped', 'tested'];
|
|
1311
|
+
|
|
1312
|
+
// @cap-api updateFeatureState(projectRoot, featureId, newState, appPath) -- Transition feature state.
|
|
1313
|
+
// @cap-todo(ref:AC-9) Enforce valid state transitions: planned->prototyped->tested->shipped
|
|
1314
|
+
// @cap-todo(ac:F-042/AC-1) Propagate transitions to ACs: tested promotes pending/prototyped ACs to tested.
|
|
1315
|
+
// @cap-todo(ac:F-042/AC-2) Propagation rule: prototyped does not change AC status; tested promotes
|
|
1316
|
+
// pending/prototyped ACs to tested; shipped requires all ACs already tested and rejects otherwise.
|
|
1317
|
+
// @cap-decision(feature:F-042) The shipped-gate REJECTS the transition by returning false (no throw).
|
|
1318
|
+
// Rationale: the existing updateFeatureState contract already returns false for any invalid transition
|
|
1319
|
+
// (unknown feature, illegal state hop, unknown state name). Throwing on the new gate would break every
|
|
1320
|
+
// caller that today relies on a boolean signal. The drift report (detectDrift) is the structured
|
|
1321
|
+
// diagnostic surface; updateFeatureState stays a simple predicate.
|
|
1322
|
+
/**
|
|
1323
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1324
|
+
* @param {string} featureId - Feature ID (e.g., "F-001")
|
|
1325
|
+
* @param {string} newState - Target state
|
|
1326
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
1327
|
+
* @returns {boolean} - True if transition was valid and applied
|
|
1328
|
+
*/
|
|
1329
|
+
function updateFeatureState(projectRoot, featureId, newState, appPath) {
|
|
1330
|
+
if (!VALID_STATES.includes(newState)) return false;
|
|
1331
|
+
|
|
1332
|
+
// @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
|
|
1333
|
+
// @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
|
|
1334
|
+
const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
|
|
1335
|
+
if (featureMap.parseError) {
|
|
1336
|
+
console.warn('cap: updateFeatureState aborted — duplicate feature ID detected: ' + String(featureMap.parseError.message).trim());
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
const feature = featureMap.features.find(f => f.id === featureId);
|
|
1340
|
+
if (!feature) return false;
|
|
1341
|
+
|
|
1342
|
+
// @cap-todo(ac:F-082/iter1 fix:2) Auto-redirect: if the looked-up feature lives in a sub-app
|
|
1343
|
+
// (metadata.subApp set) and the caller did not supply appPath, recurse with the sub-app
|
|
1344
|
+
// appPath so the mutation lands in the correct file. This eliminates the silent no-op
|
|
1345
|
+
// reported by Stage-2 #2 — root-scope writes against a sub-app feature USED to filter
|
|
1346
|
+
// the feature out and write nothing; now they explicitly route to the sub-app file.
|
|
1347
|
+
// @cap-decision(F-082/iter1 fix:2) Auto-redirect over loud rejection: more helpful UX, mirrors
|
|
1348
|
+
// the F-081 round-trip-asymmetry fix (silent loss → loud success). Recursion guard via the
|
|
1349
|
+
// `appPath` argument — when the recursion runs, appPath is set, so this branch can never
|
|
1350
|
+
// re-trigger.
|
|
1351
|
+
// @cap-risk(F-082/iter1) When `_subAppPrefixes` cannot resolve the slug (shouldn't happen
|
|
1352
|
+
// for an aggregated map, but might for a hand-built map fed through unsupported paths),
|
|
1353
|
+
// we fall back to a loud structured rejection. The console.warn names the sub-app slug so
|
|
1354
|
+
// the user knows which appPath to pass.
|
|
1355
|
+
// @cap-todo(ac:F-083/AC-6) Lazy-require monorepo helpers — see _monorepo() definition.
|
|
1356
|
+
const _mr = _monorepo();
|
|
1357
|
+
const redirectResult = _mr._maybeRedirectToSubApp(
|
|
1358
|
+
projectRoot, featureMap, feature, appPath, 'updateFeatureState',
|
|
1359
|
+
(resolvedAppPath) => updateFeatureState(projectRoot, featureId, newState, resolvedAppPath)
|
|
1360
|
+
);
|
|
1361
|
+
if (redirectResult !== _mr._NO_REDIRECT) return redirectResult;
|
|
1362
|
+
|
|
1363
|
+
const allowed = STATE_TRANSITIONS[feature.state];
|
|
1364
|
+
if (!allowed || !allowed.includes(newState)) return false;
|
|
1365
|
+
|
|
1366
|
+
// @cap-todo(ac:F-042/AC-2) shipped-gate: reject if any AC is not yet 'tested'.
|
|
1367
|
+
// Empty AC list is treated as "no obligations" and is allowed through — matches the
|
|
1368
|
+
// pre-F-042 behaviour where features without ACs could still be shipped.
|
|
1369
|
+
if (newState === 'shipped') {
|
|
1370
|
+
const blocking = feature.acs.filter(a => a.status !== 'tested');
|
|
1371
|
+
if (blocking.length > 0) return false;
|
|
1372
|
+
|
|
1373
|
+
// @cap-todo(ac:F-048/AC-3) Completeness-score gate — only enforces when config is opted in.
|
|
1374
|
+
// @cap-decision Silent failure (return false) preserves updateFeatureState's boolean contract.
|
|
1375
|
+
// Callers wanting the reason string can use transitionWithReason() instead.
|
|
1376
|
+
try {
|
|
1377
|
+
const { checkShipGate } = require('./cap-completeness.cjs');
|
|
1378
|
+
const gate = checkShipGate(featureId, newState, projectRoot);
|
|
1379
|
+
if (!gate.allowed) return false;
|
|
1380
|
+
} catch (_e) {
|
|
1381
|
+
// Completeness module unavailable — allow through for backwards compat.
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
feature.state = newState;
|
|
1386
|
+
|
|
1387
|
+
// @cap-todo(ac:F-042/AC-1) Promote ACs on transition to tested.
|
|
1388
|
+
if (newState === 'tested') {
|
|
1389
|
+
for (const ac of feature.acs) {
|
|
1390
|
+
if (ac.status === 'pending' || ac.status === 'prototyped') {
|
|
1391
|
+
ac.status = 'tested';
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
// 'planned' and 'prototyped' transitions intentionally leave ACs untouched.
|
|
1396
|
+
|
|
1397
|
+
// @cap-todo(ac:F-088/AC-5) Try the surgical-patch path first to avoid the lossy round-trip.
|
|
1398
|
+
// Build a patch list: (1) the feature state, (2) any AC promotions performed above. If the
|
|
1399
|
+
// patcher hits all of them, we're done. Otherwise (legacy header / bullet ACs / unusual
|
|
1400
|
+
// format) fall back to the original parse → write flow.
|
|
1401
|
+
const patches = [{ kind: 'state', featureId, newState }];
|
|
1402
|
+
if (newState === 'tested') {
|
|
1403
|
+
for (const ac of feature.acs) {
|
|
1404
|
+
if (ac.status === 'tested') {
|
|
1405
|
+
patches.push({ kind: 'ac', featureId, acId: ac.id, newStatus: 'tested' });
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
const surgical = applySurgicalPatches(projectRoot, appPath, patches);
|
|
1410
|
+
if (surgical.ok && surgical.misses.length === 0) {
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Fallback: legacy parse → serialize path. Still gated by the F-088 shrink-guard so a lossy
|
|
1415
|
+
// write is loud rather than silent.
|
|
1416
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// @cap-feature(feature:F-048) Transition a feature state and return a structured reason on rejection.
|
|
1421
|
+
// @cap-decision Additive API. updateFeatureState's boolean contract is preserved so existing callers
|
|
1422
|
+
// do not break; transitionWithReason exposes the completeness-score gate's reason text for UIs that
|
|
1423
|
+
// want to explain why a shipped transition was blocked.
|
|
1424
|
+
/**
|
|
1425
|
+
* Same as updateFeatureState but returns a structured result including a rejection reason.
|
|
1426
|
+
* Used by /cap:completeness-report and CLI surfaces that want to surface the gate reason.
|
|
1427
|
+
* @param {string} projectRoot
|
|
1428
|
+
* @param {string} featureId
|
|
1429
|
+
* @param {string} newState
|
|
1430
|
+
* @param {string|null} [appPath=null]
|
|
1431
|
+
* @returns {{ ok: boolean, reason: string|null, score: number|null }}
|
|
1432
|
+
*/
|
|
1433
|
+
function transitionWithReason(projectRoot, featureId, newState, appPath) {
|
|
1434
|
+
// Pre-check the completeness gate so we can provide a reason. updateFeatureState
|
|
1435
|
+
// re-checks it internally for consistency (defense in depth against stale config loads).
|
|
1436
|
+
if (newState === 'shipped') {
|
|
1437
|
+
try {
|
|
1438
|
+
const { checkShipGate } = require('./cap-completeness.cjs');
|
|
1439
|
+
const gate = checkShipGate(featureId, newState, projectRoot);
|
|
1440
|
+
if (!gate.allowed) {
|
|
1441
|
+
return { ok: false, reason: gate.reason, score: gate.score };
|
|
1442
|
+
}
|
|
1443
|
+
} catch (_e) { /* completeness module unavailable — proceed */ }
|
|
1444
|
+
}
|
|
1445
|
+
const ok = updateFeatureState(projectRoot, featureId, newState, appPath);
|
|
1446
|
+
return { ok, reason: ok ? null : 'State transition rejected by feature-map validation (wrong source state, missing AC tested status, or invalid target).', score: null };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// @cap-feature(feature:F-042) setAcStatus — explicit per-AC mutation (AC-3).
|
|
1450
|
+
// @cap-todo(ac:F-042/AC-3) New function setAcStatus(projectRoot, featureId, acId, newStatus, appPath)
|
|
1451
|
+
// for finer-grained per-AC state changes. Does NOT propagate upward to feature state.
|
|
1452
|
+
/**
|
|
1453
|
+
* Explicitly set the status of a single AC. Does not modify feature state.
|
|
1454
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1455
|
+
* @param {string} featureId - Feature ID (e.g., "F-001")
|
|
1456
|
+
* @param {string} acId - AC ID (e.g., "AC-1")
|
|
1457
|
+
* @param {string} newStatus - One of AC_VALID_STATUSES (pending | prototyped | tested)
|
|
1458
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
1459
|
+
* @returns {boolean} - True if the AC was found and updated, false otherwise
|
|
1460
|
+
*/
|
|
1461
|
+
function setAcStatus(projectRoot, featureId, acId, newStatus, appPath) {
|
|
1462
|
+
if (!AC_VALID_STATUSES.includes(newStatus)) return false;
|
|
1463
|
+
|
|
1464
|
+
// @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
|
|
1465
|
+
// @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
|
|
1466
|
+
const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
|
|
1467
|
+
if (featureMap.parseError) {
|
|
1468
|
+
console.warn('cap: setAcStatus aborted — duplicate feature ID detected: ' + String(featureMap.parseError.message).trim());
|
|
1469
|
+
return false;
|
|
1470
|
+
}
|
|
1471
|
+
const feature = featureMap.features.find(f => f.id === featureId);
|
|
1472
|
+
if (!feature) return false;
|
|
1473
|
+
|
|
1474
|
+
// @cap-todo(ac:F-082/iter1 fix:2) Auto-redirect to sub-app when feature lives there. See
|
|
1475
|
+
// updateFeatureState for the full lesson.
|
|
1476
|
+
// @cap-todo(ac:F-083/AC-6) Lazy-require monorepo helpers — see _monorepo() definition.
|
|
1477
|
+
const _mrSetAc = _monorepo();
|
|
1478
|
+
const redirectResult = _mrSetAc._maybeRedirectToSubApp(
|
|
1479
|
+
projectRoot, featureMap, feature, appPath, 'setAcStatus',
|
|
1480
|
+
(resolvedAppPath) => setAcStatus(projectRoot, featureId, acId, newStatus, resolvedAppPath)
|
|
1481
|
+
);
|
|
1482
|
+
if (redirectResult !== _mrSetAc._NO_REDIRECT) return redirectResult;
|
|
1483
|
+
|
|
1484
|
+
const ac = feature.acs.find(a => a.id === acId);
|
|
1485
|
+
if (!ac) return false;
|
|
1486
|
+
|
|
1487
|
+
ac.status = newStatus;
|
|
1488
|
+
|
|
1489
|
+
// @cap-todo(ac:F-088/AC-5) Surgical-patch fast path — avoid the lossy round-trip when the
|
|
1490
|
+
// FEATURE-MAP uses the canonical bracket-header + table-AC form. Falls back to the parse →
|
|
1491
|
+
// write path for legacy / bullet-form features.
|
|
1492
|
+
const surgical = applySurgicalPatches(projectRoot, appPath, [
|
|
1493
|
+
{ kind: 'ac', featureId, acId, newStatus },
|
|
1494
|
+
]);
|
|
1495
|
+
if (surgical.ok && surgical.misses.length === 0) {
|
|
1496
|
+
return true;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
1500
|
+
return true;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* @typedef {Object} DriftEntry
|
|
1505
|
+
* @property {string} id - Feature ID
|
|
1506
|
+
* @property {string} title - Feature title
|
|
1507
|
+
* @property {string} state - Feature state (always 'tested' or 'shipped' in a drift entry)
|
|
1508
|
+
* @property {{id: string, description: string}[]} pendingAcs - ACs still in 'pending' status
|
|
1509
|
+
* @property {number} totalAcs - Total AC count for this feature
|
|
1510
|
+
*/
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* @typedef {Object} DriftReport
|
|
1514
|
+
* @property {boolean} hasDrift - True if any features show drift
|
|
1515
|
+
* @property {number} driftCount - Number of features with drift
|
|
1516
|
+
* @property {DriftEntry[]} features - Per-feature drift details
|
|
1517
|
+
*/
|
|
1518
|
+
|
|
1519
|
+
// @cap-feature(feature:F-042) detectDrift — pure diagnostic over the parsed Feature Map (AC-4).
|
|
1520
|
+
// @cap-todo(ac:F-042/AC-4) Status drift detection: flag features where state is shipped/tested but
|
|
1521
|
+
// one or more ACs are still pending. Returns a structured DriftReport. No console output, no writes.
|
|
1522
|
+
/**
|
|
1523
|
+
* Detect features whose feature state is 'shipped' or 'tested' but where ACs remain 'pending'.
|
|
1524
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1525
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
1526
|
+
* @returns {DriftReport}
|
|
1527
|
+
*/
|
|
1528
|
+
function detectDrift(projectRoot, appPath) {
|
|
1529
|
+
// @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
|
|
1530
|
+
// @cap-decision(F-081/iter2) Warn on parseError; continue with partial map for read-only display.
|
|
1531
|
+
const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
|
|
1532
|
+
if (featureMap.parseError) {
|
|
1533
|
+
console.warn('cap: detectDrift — duplicate feature ID detected, drift report uses partial map: ' + String(featureMap.parseError.message).trim());
|
|
1534
|
+
}
|
|
1535
|
+
const driftFeatures = [];
|
|
1536
|
+
|
|
1537
|
+
for (const f of featureMap.features) {
|
|
1538
|
+
if (f.state !== 'shipped' && f.state !== 'tested') continue;
|
|
1539
|
+
const pendingAcs = f.acs.filter(a => a.status === 'pending');
|
|
1540
|
+
if (pendingAcs.length === 0) continue;
|
|
1541
|
+
|
|
1542
|
+
driftFeatures.push({
|
|
1543
|
+
id: f.id,
|
|
1544
|
+
title: f.title,
|
|
1545
|
+
state: f.state,
|
|
1546
|
+
pendingAcs: pendingAcs.map(a => ({ id: a.id, description: a.description })),
|
|
1547
|
+
totalAcs: f.acs.length,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
return {
|
|
1552
|
+
hasDrift: driftFeatures.length > 0,
|
|
1553
|
+
driftCount: driftFeatures.length,
|
|
1554
|
+
features: driftFeatures,
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// @cap-feature(feature:F-042) formatDriftReport — markdown-friendly renderer used by the
|
|
1559
|
+
// /cap:status --drift CLI (AC-6). Pure function: input report, output string. No I/O.
|
|
1560
|
+
/**
|
|
1561
|
+
* Render a DriftReport as a markdown table for CLI display.
|
|
1562
|
+
* @param {DriftReport} report
|
|
1563
|
+
* @returns {string}
|
|
1564
|
+
*/
|
|
1565
|
+
function formatDriftReport(report) {
|
|
1566
|
+
// @cap-todo(ac:F-042/AC-6) Defensive: nullish report is treated as the no-drift case so
|
|
1567
|
+
// downstream CLI shells never explode when the upstream pipeline hands back a missing
|
|
1568
|
+
// value (e.g. F-043 reconciliation tooling that may short-circuit before producing a report).
|
|
1569
|
+
if (!report || !report.hasDrift) {
|
|
1570
|
+
return 'Status Drift: none — Feature Map is consistent.';
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
const lines = [];
|
|
1574
|
+
lines.push(`Status Drift Detected: ${report.driftCount} features`);
|
|
1575
|
+
lines.push('');
|
|
1576
|
+
lines.push('| Feature | State | Pending ACs |');
|
|
1577
|
+
lines.push('|---------|----------|-------------|');
|
|
1578
|
+
for (const f of report.features) {
|
|
1579
|
+
// pad state column to roughly the width of "shipped" + 1
|
|
1580
|
+
const statePadded = f.state.padEnd(8, ' ');
|
|
1581
|
+
const ratio = `${f.pendingAcs.length}/${f.totalAcs}`;
|
|
1582
|
+
lines.push(`| ${f.id} | ${statePadded} | ${ratio.padEnd(11, ' ')} |`);
|
|
1583
|
+
}
|
|
1584
|
+
return lines.join('\n');
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// @cap-api enrichFromTags(projectRoot, scanResults, appPath) -- Update file references from tag scan.
|
|
1588
|
+
// @cap-todo(ref:AC-12) Feature Map auto-enriched from @cap-feature tags found in source code
|
|
1589
|
+
/**
|
|
1590
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1591
|
+
* @param {import('./cap-tag-scanner.cjs').CapTag[]} scanResults - Tags from cap-tag-scanner
|
|
1592
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
1593
|
+
* @returns {FeatureMap}
|
|
1594
|
+
*/
|
|
1595
|
+
function enrichFromTags(projectRoot, scanResults, appPath) {
|
|
1596
|
+
// @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
|
|
1597
|
+
const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
|
|
1598
|
+
|
|
1599
|
+
// @cap-todo(ac:F-082/iter1 fix:1) Monorepo-aware enrichment. Stage-2 #1 found that on a
|
|
1600
|
+
// monorepo project (Rescoped Table present), the legacy bare `enrichFromTags(root, tags)`
|
|
1601
|
+
// call read the AGGREGATED map (sub-app features included), mutated their `files[]` in
|
|
1602
|
+
// memory, then wrote to root — where the writer-filter at writeFeatureMap (L894+) silently
|
|
1603
|
+
// stripped them out. Net effect: every sub-app `@cap-feature(...)` tag was dropped on every
|
|
1604
|
+
// `/cap:scan`. Production-bite class.
|
|
1605
|
+
// @cap-decision(F-082/iter1 fix:1) Internal-split strategy. Detect the aggregated map via
|
|
1606
|
+
// the runtime-only `_subAppPrefixes`. If present and caller did not specify appPath,
|
|
1607
|
+
// group features by `metadata.subApp` and write each group back via the appropriate appPath.
|
|
1608
|
+
// API surface unchanged — callers in commands/cap/{scan,prototype,iterate,annotate}.md
|
|
1609
|
+
// continue to call `enrichFromTags(process.cwd(), tags)` and now Just Work for monorepos.
|
|
1610
|
+
// @cap-risk(F-082/iter1 fix:1) The same scanResults are applied to every per-scope write —
|
|
1611
|
+
// each enrichment loop re-filters tags against the features it owns (find returns null for
|
|
1612
|
+
// a foreign feature, so the file ref is not persisted to a wrong sub-app file).
|
|
1613
|
+
// @cap-decision(F-082/followup) Cross-sub-app blast radius fix: parseError gate is now
|
|
1614
|
+
// evaluated AFTER the aggregation-detection branch. An aggregated parseError (a duplicate
|
|
1615
|
+
// in ONE sub-app) must NOT block enrichment for healthy sibling sub-apps — the aggregator
|
|
1616
|
+
// `_enrichFromTagsAcrossSubApps` already skips bad scopes individually at L1671-1675. The
|
|
1617
|
+
// gate below applies ONLY to legacy single-scope reads (no _subAppPrefixes).
|
|
1618
|
+
if (!appPath && featureMap._subAppPrefixes && featureMap._subAppPrefixes.size > 0) {
|
|
1619
|
+
// @cap-todo(ac:F-083/AC-6) Lazy-require — see _monorepo() definition.
|
|
1620
|
+
return _monorepo()._enrichFromTagsAcrossSubApps(projectRoot, scanResults, featureMap);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
|
|
1624
|
+
// Single-scope only; aggregated reads handle parseError per-scope (see above).
|
|
1625
|
+
if (featureMap.parseError) {
|
|
1626
|
+
console.warn('cap: skipping enrichFromTags — duplicate feature ID detected: ' + _safeForError(featureMap.parseError.message));
|
|
1627
|
+
return featureMap;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
for (const tag of scanResults) {
|
|
1631
|
+
if (tag.type !== 'feature') continue;
|
|
1632
|
+
const featureId = tag.metadata.feature;
|
|
1633
|
+
if (!featureId) continue;
|
|
1634
|
+
|
|
1635
|
+
const feature = featureMap.features.find(f => f.id === featureId);
|
|
1636
|
+
if (!feature) continue;
|
|
1637
|
+
|
|
1638
|
+
// Add file reference if not already present
|
|
1639
|
+
if (!feature.files.includes(tag.file)) {
|
|
1640
|
+
feature.files.push(tag.file);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
1645
|
+
return featureMap;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// @cap-feature(feature:F-063) enrichFromDesignTags — populate Feature.usesDesign from design-token/design-component tags.
|
|
1649
|
+
// @cap-api enrichFromDesignTags(projectRoot, scanResults, appPath) -- Add DT/DC IDs to each feature's usesDesign
|
|
1650
|
+
// based on where design-token / design-component tags co-locate with a feature's files.
|
|
1651
|
+
// @cap-todo(ac:F-063/AC-3) Design-usage enrichment: when a file tagged @cap-feature(feature:F-NNN) also
|
|
1652
|
+
// carries @cap-design-token(id:DT-NNN) or @cap-design-component(id:DC-NNN), the ID is appended to F-NNN.usesDesign.
|
|
1653
|
+
// @cap-decision Co-location (same file) is the heuristic. Cross-file usage would require import resolution,
|
|
1654
|
+
// which /cap:design --scope handles explicitly (user-curated). This keeps the scanner pure and the UX predictable.
|
|
1655
|
+
/**
|
|
1656
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1657
|
+
* @param {import('./cap-tag-scanner.cjs').CapTag[]} scanResults - Tags from cap-tag-scanner (must include design-token/design-component entries)
|
|
1658
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
1659
|
+
* @returns {FeatureMap}
|
|
1660
|
+
*/
|
|
1661
|
+
function enrichFromDesignTags(projectRoot, scanResults, appPath) {
|
|
1662
|
+
// @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
|
|
1663
|
+
const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
|
|
1664
|
+
|
|
1665
|
+
// @cap-todo(ac:F-082/iter1 fix:1) Monorepo-aware design enrichment — same lesson as enrichFromTags.
|
|
1666
|
+
// @cap-decision(F-082/followup) Cross-sub-app blast radius fix: parseError gate moved BELOW
|
|
1667
|
+
// the aggregation branch so a duplicate in one sub-app does not block healthy siblings.
|
|
1668
|
+
// See enrichFromTags for the full reasoning.
|
|
1669
|
+
if (!appPath && featureMap._subAppPrefixes && featureMap._subAppPrefixes.size > 0) {
|
|
1670
|
+
// @cap-todo(ac:F-083/AC-6) Lazy-require — see _monorepo() definition.
|
|
1671
|
+
return _monorepo()._enrichFromDesignTagsAcrossSubApps(projectRoot, scanResults, featureMap);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
|
|
1675
|
+
// Single-scope only; aggregated reads handle parseError per-scope.
|
|
1676
|
+
if (featureMap.parseError) {
|
|
1677
|
+
console.warn('cap: skipping enrichFromDesignTags — duplicate feature ID detected: ' + _safeForError(featureMap.parseError.message));
|
|
1678
|
+
return featureMap;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Build file -> featureId map (first @cap-feature wins, matches F-049 convention).
|
|
1682
|
+
const fileToFeature = new Map();
|
|
1683
|
+
for (const tag of scanResults) {
|
|
1684
|
+
if (tag.type !== 'feature') continue;
|
|
1685
|
+
const fid = tag.metadata && tag.metadata.feature;
|
|
1686
|
+
if (!fid) continue;
|
|
1687
|
+
if (!fileToFeature.has(tag.file)) fileToFeature.set(tag.file, fid);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// For each design tag, find its file's feature and append the design ID.
|
|
1691
|
+
for (const tag of scanResults) {
|
|
1692
|
+
if (tag.type !== 'design-token' && tag.type !== 'design-component') continue;
|
|
1693
|
+
const designId = tag.metadata && tag.metadata.id;
|
|
1694
|
+
if (!designId) continue;
|
|
1695
|
+
const featureId = fileToFeature.get(tag.file);
|
|
1696
|
+
if (!featureId) continue;
|
|
1697
|
+
|
|
1698
|
+
const feature = featureMap.features.find(f => f.id === featureId);
|
|
1699
|
+
if (!feature) continue;
|
|
1700
|
+
if (!Array.isArray(feature.usesDesign)) feature.usesDesign = [];
|
|
1701
|
+
if (!feature.usesDesign.includes(designId)) feature.usesDesign.push(designId);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Stable sort for deterministic output.
|
|
1705
|
+
for (const f of featureMap.features) {
|
|
1706
|
+
if (Array.isArray(f.usesDesign)) f.usesDesign.sort();
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
1710
|
+
return featureMap;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// @cap-api setFeatureUsesDesign(projectRoot, featureId, designIds, appPath) -- Replace a feature's usesDesign list.
|
|
1714
|
+
// @cap-todo(ac:F-063/AC-4) Called by /cap:design --scope after the user confirms which DT/DC IDs the feature uses.
|
|
1715
|
+
/**
|
|
1716
|
+
* @param {string} projectRoot
|
|
1717
|
+
* @param {string} featureId - e.g. "F-023"
|
|
1718
|
+
* @param {string[]} designIds - list of DT-NNN / DC-NNN IDs (replaces existing value)
|
|
1719
|
+
* @param {string|null} [appPath=null]
|
|
1720
|
+
* @returns {boolean} - true if the feature existed and was updated
|
|
1721
|
+
*/
|
|
1722
|
+
function setFeatureUsesDesign(projectRoot, featureId, designIds, appPath) {
|
|
1723
|
+
// @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
|
|
1724
|
+
// @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
|
|
1725
|
+
const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
|
|
1726
|
+
if (featureMap.parseError) {
|
|
1727
|
+
console.warn('cap: setFeatureUsesDesign aborted — duplicate feature ID detected: ' + String(featureMap.parseError.message).trim());
|
|
1728
|
+
return false;
|
|
1729
|
+
}
|
|
1730
|
+
const feature = featureMap.features.find(f => f.id === featureId);
|
|
1731
|
+
if (!feature) return false;
|
|
1732
|
+
|
|
1733
|
+
// @cap-todo(ac:F-082/iter1 fix:2) Auto-redirect to sub-app when feature lives there. See
|
|
1734
|
+
// updateFeatureState for the full lesson.
|
|
1735
|
+
// @cap-todo(ac:F-083/AC-6) Lazy-require monorepo helpers — see _monorepo() definition.
|
|
1736
|
+
const _mrSetUd = _monorepo();
|
|
1737
|
+
const redirectResult = _mrSetUd._maybeRedirectToSubApp(
|
|
1738
|
+
projectRoot, featureMap, feature, appPath, 'setFeatureUsesDesign',
|
|
1739
|
+
(resolvedAppPath) => setFeatureUsesDesign(projectRoot, featureId, designIds, resolvedAppPath)
|
|
1740
|
+
);
|
|
1741
|
+
if (redirectResult !== _mrSetUd._NO_REDIRECT) return redirectResult;
|
|
1742
|
+
|
|
1743
|
+
const cleaned = (Array.isArray(designIds) ? designIds : [])
|
|
1744
|
+
.map(s => String(s).trim())
|
|
1745
|
+
.filter(s => /^(DT-\d{3,}|DC-\d{3,})$/.test(s));
|
|
1746
|
+
// Stable, deterministic order.
|
|
1747
|
+
feature.usesDesign = [...new Set(cleaned)].sort();
|
|
1748
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
1749
|
+
return true;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// @cap-api enrichFromDeps(projectRoot) -- Read package.json, detect imports, add dependency info to features.
|
|
1753
|
+
// @cap-todo(ref:AC-13) Feature Map auto-enriched from dependency graph analysis, env vars, package.json
|
|
1754
|
+
/**
|
|
1755
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1756
|
+
* @returns {{ dependencies: string[], devDependencies: string[], envVars: string[] }}
|
|
1757
|
+
*/
|
|
1758
|
+
function enrichFromDeps(projectRoot) {
|
|
1759
|
+
const result = { dependencies: [], devDependencies: [], envVars: [] };
|
|
1760
|
+
|
|
1761
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
1762
|
+
if (fs.existsSync(pkgPath)) {
|
|
1763
|
+
try {
|
|
1764
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
1765
|
+
if (pkg.dependencies) result.dependencies = Object.keys(pkg.dependencies);
|
|
1766
|
+
if (pkg.devDependencies) result.devDependencies = Object.keys(pkg.devDependencies);
|
|
1767
|
+
} catch (_e) {
|
|
1768
|
+
// Malformed package.json
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// Scan for .env file to detect environment variables
|
|
1773
|
+
const envPath = path.join(projectRoot, '.env');
|
|
1774
|
+
if (fs.existsSync(envPath)) {
|
|
1775
|
+
try {
|
|
1776
|
+
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
1777
|
+
const envRE = /^([A-Z_][A-Z0-9_]*)=/gm;
|
|
1778
|
+
let match;
|
|
1779
|
+
while ((match = envRE.exec(envContent)) !== null) {
|
|
1780
|
+
result.envVars.push(match[1]);
|
|
1781
|
+
}
|
|
1782
|
+
} catch (_e) {
|
|
1783
|
+
// Ignore
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
return result;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// @cap-api getNextFeatureId(features) -- Generate next F-NNN ID.
|
|
1791
|
+
/**
|
|
1792
|
+
* @param {Feature[]} features - Existing features
|
|
1793
|
+
* @returns {string} - Next feature ID (e.g., "F-001")
|
|
1794
|
+
*/
|
|
1795
|
+
function getNextFeatureId(features) {
|
|
1796
|
+
if (!features || features.length === 0) return 'F-001';
|
|
1797
|
+
|
|
1798
|
+
let maxNum = 0;
|
|
1799
|
+
for (const f of features) {
|
|
1800
|
+
const match = f.id.match(/^F-(\d+)$/);
|
|
1801
|
+
if (match) {
|
|
1802
|
+
const num = parseInt(match[1], 10);
|
|
1803
|
+
if (num > maxNum) maxNum = num;
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
return `F-${String(maxNum + 1).padStart(3, '0')}`;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// @cap-api enrichFromScan(featureMap, tags) -- Updates Feature Map status from tag scan results.
|
|
1811
|
+
// Returns: updated FeatureMap with AC statuses reflecting code annotations.
|
|
1812
|
+
/**
|
|
1813
|
+
* @param {FeatureMap} featureMap - Current feature map data
|
|
1814
|
+
* @param {import('./cap-tag-scanner.cjs').CapTag[]} tags - Tags from cap-tag-scanner
|
|
1815
|
+
* @returns {FeatureMap}
|
|
1816
|
+
*/
|
|
1817
|
+
function enrichFromScan(featureMap, tags) {
|
|
1818
|
+
for (const tag of tags) {
|
|
1819
|
+
if (tag.type !== 'feature') continue;
|
|
1820
|
+
const featureId = tag.metadata.feature;
|
|
1821
|
+
if (!featureId) continue;
|
|
1822
|
+
|
|
1823
|
+
const feature = featureMap.features.find(f => f.id === featureId);
|
|
1824
|
+
if (!feature) continue;
|
|
1825
|
+
|
|
1826
|
+
// Add file reference
|
|
1827
|
+
if (!feature.files.includes(tag.file)) {
|
|
1828
|
+
feature.files.push(tag.file);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// If AC reference in metadata, mark it as implemented
|
|
1832
|
+
const acRef = tag.metadata.ac;
|
|
1833
|
+
if (acRef) {
|
|
1834
|
+
const ac = feature.acs.find(a => a.id === acRef);
|
|
1835
|
+
if (ac && ac.status === 'pending') {
|
|
1836
|
+
ac.status = 'implemented';
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return featureMap;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// @cap-api addFeatures(featureMap, newFeatures) -- Adds new features to an existing Feature Map (from brainstorm).
|
|
1845
|
+
// @cap-todo(ref:AC-11) Feature Map supports auto-derivation from brainstorm output
|
|
1846
|
+
/**
|
|
1847
|
+
* @param {FeatureMap} featureMap - Current feature map data
|
|
1848
|
+
* @param {Feature[]} newFeatures - Features to add
|
|
1849
|
+
* @returns {FeatureMap}
|
|
1850
|
+
*/
|
|
1851
|
+
function addFeatures(featureMap, newFeatures) {
|
|
1852
|
+
const existingIds = new Set(featureMap.features.map(f => f.id));
|
|
1853
|
+
const existingTitles = new Set(featureMap.features.map(f => f.title.toLowerCase()));
|
|
1854
|
+
|
|
1855
|
+
for (const nf of newFeatures) {
|
|
1856
|
+
// Skip duplicates by ID or title
|
|
1857
|
+
if (existingIds.has(nf.id)) continue;
|
|
1858
|
+
if (existingTitles.has(nf.title.toLowerCase())) continue;
|
|
1859
|
+
|
|
1860
|
+
featureMap.features.push(nf);
|
|
1861
|
+
existingIds.add(nf.id);
|
|
1862
|
+
existingTitles.add(nf.title.toLowerCase());
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
return featureMap;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// @cap-api getStatus(featureMap) -- Computes aggregate project status from Feature Map.
|
|
1869
|
+
/**
|
|
1870
|
+
* @param {FeatureMap} featureMap
|
|
1871
|
+
* @returns {{ totalFeatures: number, completedFeatures: number, totalACs: number, implementedACs: number, testedACs: number, reviewedACs: number }}
|
|
1872
|
+
*/
|
|
1873
|
+
function getStatus(featureMap) {
|
|
1874
|
+
let totalFeatures = featureMap.features.length;
|
|
1875
|
+
let completedFeatures = featureMap.features.filter(f => f.state === 'shipped').length;
|
|
1876
|
+
let totalACs = 0;
|
|
1877
|
+
let implementedACs = 0;
|
|
1878
|
+
let testedACs = 0;
|
|
1879
|
+
let reviewedACs = 0;
|
|
1880
|
+
|
|
1881
|
+
for (const f of featureMap.features) {
|
|
1882
|
+
totalACs += f.acs.length;
|
|
1883
|
+
for (const ac of f.acs) {
|
|
1884
|
+
if (ac.status === 'implemented') implementedACs++;
|
|
1885
|
+
if (ac.status === 'tested') testedACs++;
|
|
1886
|
+
if (ac.status === 'reviewed') reviewedACs++;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
return { totalFeatures, completedFeatures, totalACs, implementedACs, testedACs, reviewedACs };
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// @cap-feature(feature:F-083) Module exports assigned in TWO STAGES — Stage 1 attaches the
|
|
1894
|
+
// locally-defined exports; Stage 2 (the trailing block) attaches identity-preserving
|
|
1895
|
+
// references to the monorepo module's exports. AC-2 pins the identity-preservation contract.
|
|
1896
|
+
// @cap-decision(F-083/backward-compat) Re-exports preserve zero call-site change contract.
|
|
1897
|
+
module.exports = {
|
|
1898
|
+
FEATURE_MAP_FILE,
|
|
1899
|
+
FEATURE_ID_PATTERN, // F-081
|
|
1900
|
+
VALID_STATES,
|
|
1901
|
+
STATE_TRANSITIONS,
|
|
1902
|
+
AC_VALID_STATUSES,
|
|
1903
|
+
generateTemplate,
|
|
1904
|
+
readFeatureMap,
|
|
1905
|
+
readCapConfig, // F-081
|
|
1906
|
+
writeFeatureMap,
|
|
1907
|
+
parseFeatureMapContent,
|
|
1908
|
+
serializeFeatureMap,
|
|
1909
|
+
addFeature,
|
|
1910
|
+
updateFeatureState,
|
|
1911
|
+
transitionWithReason,
|
|
1912
|
+
setAcStatus,
|
|
1913
|
+
detectDrift,
|
|
1914
|
+
formatDriftReport,
|
|
1915
|
+
enrichFromTags,
|
|
1916
|
+
enrichFromDesignTags, // F-063
|
|
1917
|
+
setFeatureUsesDesign, // F-063
|
|
1918
|
+
enrichFromDeps,
|
|
1919
|
+
getNextFeatureId,
|
|
1920
|
+
enrichFromScan,
|
|
1921
|
+
addFeatures,
|
|
1922
|
+
getStatus,
|
|
1923
|
+
// @cap-todo(ac:F-083/AC-1) Internal helper exposed for the monorepo module's lazy-require.
|
|
1924
|
+
// Not part of the documented public surface, but the monorepo module destructures it.
|
|
1925
|
+
_safeForError,
|
|
1926
|
+
// F-088 surgical-patch helpers (exposed for tests + downstream callers like cap-reconcile).
|
|
1927
|
+
applySurgicalPatches,
|
|
1928
|
+
_surgicalUpdateFeatureState,
|
|
1929
|
+
_surgicalSetAcStatus,
|
|
1930
|
+
};
|
|
1931
|
+
|
|
1932
|
+
// @cap-todo(ac:F-083/AC-2) Stage-2 re-export attachment — identity-preserving wiring of the
|
|
1933
|
+
// monorepo module's exports onto this module's surface. Runs AFTER Stage 1 above.
|
|
1934
|
+
{
|
|
1935
|
+
const _mr = _monorepo();
|
|
1936
|
+
for (const k of [
|
|
1937
|
+
'parseRescopedTable', 'discoverSubAppFeatureMaps', 'aggregateSubAppFeatureMaps',
|
|
1938
|
+
'extractRescopedBlock', 'injectRescopedBlock',
|
|
1939
|
+
'_enrichFromTagsAcrossSubApps', '_enrichFromDesignTagsAcrossSubApps',
|
|
1940
|
+
'_maybeRedirectToSubApp', '_NO_REDIRECT',
|
|
1941
|
+
'initAppFeatureMap', 'listAppFeatureMaps', 'rescopeFeatures',
|
|
1942
|
+
]) module.exports[k] = _mr[k];
|
|
1943
|
+
}
|