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,885 @@
|
|
|
1
|
+
// @cap-feature(feature:F-083, primary:true) Monorepo aggregation module — extracted from
|
|
2
|
+
// cap-feature-map.cjs in F-083. Hosts the Rescoped-Table reader, the directory-walk discoverer,
|
|
3
|
+
// the cross-sub-app aggregator, the auto-redirect helper, and the per-scope batch-enrichment
|
|
4
|
+
// helpers. Public API is re-exported from cap-feature-map.cjs (zero call-site change).
|
|
5
|
+
// @cap-decision(F-083/cycle) Lazy-require both directions: this module's _core() and
|
|
6
|
+
// cap-feature-map.cjs's _monorepo() are mirror accessors invoked INSIDE function bodies
|
|
7
|
+
// (never at module top-level). AC-6 static-analysis test pins the no-cycle contract.
|
|
8
|
+
// @cap-constraint Zero external dependencies — Node.js built-ins only (fs, path).
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
// @cap-decision(F-083/followup) F-083-FIX-A: shared constants moved to cap-feature-map-internals.cjs
|
|
16
|
+
// to eliminate the duplicated `FEATURE_MAP_FILE` declaration with cap-feature-map.cjs.
|
|
17
|
+
// Single source of truth — future-drift impossible by construction.
|
|
18
|
+
const { FEATURE_MAP_FILE } = require('./cap-feature-map-internals.cjs');
|
|
19
|
+
|
|
20
|
+
// @cap-todo(ac:F-083/AC-6) Lazy accessor for cap-feature-map.cjs — required INSIDE function
|
|
21
|
+
// bodies, NEVER at module top-level. Memoized so cached-require cost is paid once per process.
|
|
22
|
+
let _coreCache = null;
|
|
23
|
+
function _core() {
|
|
24
|
+
if (!_coreCache) _coreCache = require('./cap-feature-map.cjs');
|
|
25
|
+
return _coreCache;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// @cap-feature(feature:F-082) parseRescopedTable — read-side counterpart to rescopeFeatures
|
|
29
|
+
// writer. Detects the "Rescoped Feature Maps" section in root FEATURE-MAP.md and extracts
|
|
30
|
+
// the listed sub-app paths.
|
|
31
|
+
// @cap-decision(F-082/AC-1) The header is matched case-insensitively but anchored on a markdown
|
|
32
|
+
// header line (## or ###) followed by literal "Rescoped Feature Maps". This avoids false
|
|
33
|
+
// positives when an AC description happens to mention the phrase in prose.
|
|
34
|
+
// @cap-decision(F-082/AC-1) Each table row's first column may be either a backtick-quoted path
|
|
35
|
+
// ("`apps/web/`") or a plain-text path. Trailing slash is tolerated and stripped. Markdown
|
|
36
|
+
// link syntax `[apps/web](apps/web/FEATURE-MAP.md)` is also accepted — that's the form the
|
|
37
|
+
// /cap:rescope writer is expected to emit when writing the table back.
|
|
38
|
+
// @cap-todo(ac:F-083/AC-1) Exported from this module as part of the F-083 split surface.
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} content - Raw FEATURE-MAP.md content
|
|
41
|
+
* @returns {Array<{appPath: string, line: number}>} - Sub-app paths in declaration order
|
|
42
|
+
*/
|
|
43
|
+
function parseRescopedTable(content) {
|
|
44
|
+
if (typeof content !== 'string' || content.length === 0) return [];
|
|
45
|
+
const lines = content.split('\n');
|
|
46
|
+
const headerRE = /^#{2,4}\s+Rescoped\s+Feature\s+Maps\s*$/i;
|
|
47
|
+
let inSection = false;
|
|
48
|
+
let inTable = false;
|
|
49
|
+
/** @type {Array<{appPath: string, line: number}>} */
|
|
50
|
+
const entries = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const line = lines[i];
|
|
55
|
+
if (headerRE.test(line)) {
|
|
56
|
+
inSection = true;
|
|
57
|
+
inTable = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!inSection) continue;
|
|
61
|
+
// Exit the section on the next markdown header.
|
|
62
|
+
if (/^#{1,6}\s+/.test(line)) {
|
|
63
|
+
if (inSection && !headerRE.test(line)) {
|
|
64
|
+
inSection = false;
|
|
65
|
+
inTable = false;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// Recognise table header / separator.
|
|
70
|
+
if (/^\|.*\|$/.test(line)) {
|
|
71
|
+
// table separator line "|---|---|"
|
|
72
|
+
if (/^\|[\s:-]+\|/.test(line)) {
|
|
73
|
+
inTable = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// table header line — typically "| App | Features | …"
|
|
77
|
+
if (!inTable && /^\|\s*App\b/i.test(line)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// table data row
|
|
81
|
+
if (inTable) {
|
|
82
|
+
const cells = line.slice(1, -1).split('|').map(c => c.trim());
|
|
83
|
+
if (cells.length === 0) continue;
|
|
84
|
+
// @cap-decision(F-082/AC-1) Prefer the cell that looks most path-like (contains "/"
|
|
85
|
+
// or starts with "apps/"/"packages/"). The Rescoped Table writer (rescopeFeatures)
|
|
86
|
+
// emits the path in column 2 ("| App | Path | Features |"), but legacy hand-written
|
|
87
|
+
// tables sometimes put the path in column 1. Walking the row and picking the most
|
|
88
|
+
// path-like cell keeps both shapes working.
|
|
89
|
+
let extracted = null;
|
|
90
|
+
for (const c of cells) {
|
|
91
|
+
const candidate = _extractAppPath(c);
|
|
92
|
+
if (!candidate) continue;
|
|
93
|
+
if (candidate.includes('/') || /^(apps|packages)$/i.test(candidate.split('/')[0])) {
|
|
94
|
+
extracted = candidate;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (!extracted) extracted = candidate;
|
|
98
|
+
}
|
|
99
|
+
if (!extracted) continue;
|
|
100
|
+
if (seen.has(extracted)) continue;
|
|
101
|
+
seen.add(extracted);
|
|
102
|
+
entries.push({ appPath: extracted, line: i + 1 });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Bullet form fallback: "- `apps/web/`" or "- apps/web/FEATURE-MAP.md".
|
|
107
|
+
const bullet = line.match(/^[\s]*[-*]\s+(.+?)\s*$/);
|
|
108
|
+
if (bullet) {
|
|
109
|
+
const extracted = _extractAppPath(bullet[1]);
|
|
110
|
+
if (extracted && !seen.has(extracted)) {
|
|
111
|
+
seen.add(extracted);
|
|
112
|
+
entries.push({ appPath: extracted, line: i + 1 });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract a normalized app path from one cell of the Rescoped table or a bullet line.
|
|
121
|
+
* Tolerates: backtick-quoted, markdown link, trailing slash, "FEATURE-MAP.md" suffix.
|
|
122
|
+
* @param {string} raw
|
|
123
|
+
* @returns {string|null}
|
|
124
|
+
*/
|
|
125
|
+
function _extractAppPath(raw) {
|
|
126
|
+
if (typeof raw !== 'string') return null;
|
|
127
|
+
let s = raw.trim();
|
|
128
|
+
// Markdown link: [label](apps/web/FEATURE-MAP.md) -> use the URL.
|
|
129
|
+
const linkMatch = s.match(/^\[[^\]]*\]\(([^)]+)\)$/);
|
|
130
|
+
if (linkMatch) s = linkMatch[1].trim();
|
|
131
|
+
// Backtick-quoted.
|
|
132
|
+
const tickMatch = s.match(/^`([^`]+)`$/);
|
|
133
|
+
if (tickMatch) s = tickMatch[1].trim();
|
|
134
|
+
// Strip "/FEATURE-MAP.md" suffix.
|
|
135
|
+
s = s.replace(/\/?FEATURE-MAP\.md$/i, '');
|
|
136
|
+
// Strip trailing slash.
|
|
137
|
+
s = s.replace(/\/+$/, '');
|
|
138
|
+
if (!s) return null;
|
|
139
|
+
// @cap-risk(F-082) Reject absolute paths and parent-dir traversal — only relative paths
|
|
140
|
+
// anchored within the project root make sense here. Defense in depth; the caller will
|
|
141
|
+
// re-validate when resolving against projectRoot.
|
|
142
|
+
if (path.isAbsolute(s)) return null;
|
|
143
|
+
if (s.split('/').some(seg => seg === '..' || seg === '')) return null;
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// @cap-feature(feature:F-082) discoverSubAppFeatureMaps — opt-in directory walk for
|
|
148
|
+
// `apps/*/FEATURE-MAP.md` and `packages/*/FEATURE-MAP.md` when
|
|
149
|
+
// `cap.config.json:featureMaps.discover === "auto"`.
|
|
150
|
+
// @cap-decision(F-082/AC-3) Walk only the standard monorepo conventions (`apps/*`, `packages/*`)
|
|
151
|
+
// one level deep. Deeper walks invite directory traversal pathologies and the "table-only"
|
|
152
|
+
// default already covers the explicit-opt-in case. Users with non-standard layouts are
|
|
153
|
+
// expected to maintain a Rescoped Table.
|
|
154
|
+
// @cap-risk(F-082/path-traversal) We never accept user-supplied sub-app paths from config —
|
|
155
|
+
// only paths discovered via fs.readdirSync inside `projectRoot` are returned. Defense in depth
|
|
156
|
+
// against a poisoned `cap.config.json` — even if `featureMaps.discover` becomes a string like
|
|
157
|
+
// "../../etc", we read it solely to gate the walk; we do NOT treat it as a path.
|
|
158
|
+
// @cap-todo(ac:F-083/AC-1) Exported from this module as part of the F-083 split surface.
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} projectRoot
|
|
161
|
+
* @returns {Array<{appPath: string}>}
|
|
162
|
+
*/
|
|
163
|
+
function discoverSubAppFeatureMaps(projectRoot) {
|
|
164
|
+
const targets = [];
|
|
165
|
+
for (const top of ['apps', 'packages']) {
|
|
166
|
+
const topDir = path.join(projectRoot, top);
|
|
167
|
+
if (!fs.existsSync(topDir)) continue;
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = fs.readdirSync(topDir, { withFileTypes: true });
|
|
171
|
+
} catch (_e) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
for (const e of entries) {
|
|
175
|
+
if (!e.isDirectory()) continue;
|
|
176
|
+
if (e.name.startsWith('.')) continue;
|
|
177
|
+
const sub = path.join(topDir, e.name);
|
|
178
|
+
const fmPath = path.join(sub, FEATURE_MAP_FILE);
|
|
179
|
+
if (!fs.existsSync(fmPath)) continue;
|
|
180
|
+
// Defense-in-depth: ensure the resolved path stays inside projectRoot.
|
|
181
|
+
const resolved = path.resolve(sub);
|
|
182
|
+
const root = path.resolve(projectRoot);
|
|
183
|
+
if (!resolved.startsWith(root + path.sep) && resolved !== root) continue;
|
|
184
|
+
targets.push({ appPath: path.relative(projectRoot, sub).replace(/\\/g, '/') });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return targets;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// @cap-feature(feature:F-082) aggregateSubAppFeatureMaps — merge per-sub-app feature lists
|
|
191
|
+
// into a single map view. Each aggregated feature is a SHALLOW CLONE with `metadata.subApp`
|
|
192
|
+
// set to the last path segment of the sub-app (e.g. `apps/web` -> `"web"`).
|
|
193
|
+
// @cap-decision(F-082/AC-2) Clone before annotating: the cached read result of any internal
|
|
194
|
+
// call to readFeatureMap(subAppPath) must not be mutated. Mutation would leak runtime-only
|
|
195
|
+
// `subApp` markers across separate read calls and silently change downstream behavior.
|
|
196
|
+
// @cap-decision(F-082/AC-7) Cross-sub-app duplicate detection runs AFTER each per-sub-app
|
|
197
|
+
// parser has accepted its own map. The aggregated parseError keeps the same `code` as the
|
|
198
|
+
// single-map case ('CAP_DUPLICATE_FEATURE_ID') so downstream handlers don't need a new
|
|
199
|
+
// branch — the new fields (`firstSubApp`, `duplicateSubApp`, `firstFile`, `duplicateFile`)
|
|
200
|
+
// are additive and safe to ignore.
|
|
201
|
+
// @cap-todo(ac:F-083/AC-1) Exported from this module as part of the F-083 split surface.
|
|
202
|
+
/**
|
|
203
|
+
* @param {string} projectRoot
|
|
204
|
+
* @param {import('./cap-feature-map.cjs').FeatureMap} rootResult - The parse result of the root FEATURE-MAP.md
|
|
205
|
+
* @param {Array<{appPath: string}>} targets - Sub-app paths to aggregate
|
|
206
|
+
* @param {{ safe?: boolean }} aggOptions
|
|
207
|
+
* @returns {import('./cap-feature-map.cjs').FeatureMap}
|
|
208
|
+
*/
|
|
209
|
+
function aggregateSubAppFeatureMaps(projectRoot, rootResult, targets, aggOptions) {
|
|
210
|
+
// @cap-todo(ac:F-083/AC-6) Lazy require — see _core() above.
|
|
211
|
+
const { parseFeatureMapContent } = _core();
|
|
212
|
+
const safe = Boolean(aggOptions && aggOptions.safe === true);
|
|
213
|
+
/** @type {import('./cap-feature-map.cjs').Feature[]} */
|
|
214
|
+
const merged = [];
|
|
215
|
+
/** @type {Map<string, {subApp: string|null, file: string}>} */
|
|
216
|
+
const seenIds = new Map();
|
|
217
|
+
/** @type {import('./cap-feature-map.cjs').ParseError|undefined} */
|
|
218
|
+
let aggParseError = rootResult && rootResult.parseError ? rootResult.parseError : undefined;
|
|
219
|
+
let lastScan = rootResult ? rootResult.lastScan : null;
|
|
220
|
+
// @cap-todo(ac:F-082/iter1 fix:2) Build a runtime-only sub-app prefix index (slug -> appPath).
|
|
221
|
+
// Used by mutation functions (updateFeatureState/setAcStatus/etc.) to auto-redirect a
|
|
222
|
+
// write that targets a sub-app feature when the caller did NOT supply appPath.
|
|
223
|
+
// @cap-decision(F-082/iter1 fix:2) Underscore-prefixed (`_subAppPrefixes`) signals runtime-only,
|
|
224
|
+
// never persisted, mirroring `_inputFormat` and `metadata.subApp` runtime fields. Filtered
|
|
225
|
+
// away by the serializer (it never reads any underscore-prefixed key on the FeatureMap).
|
|
226
|
+
// @cap-risk(F-082/iter1) Multiple sub-apps with the same trailing slug (e.g. `apps/web` and
|
|
227
|
+
// `vendor/web`) would collide in this map. Mitigation: first-wins semantics matches the
|
|
228
|
+
// classifier-side prefix map in cap-memory-migrate.cjs. The Rescoped Table is expected to
|
|
229
|
+
// use unique slugs in practice; collision is a config-smell that surfaces at write-time.
|
|
230
|
+
/** @type {Map<string, string>} subApp slug -> sub-app relative prefix */
|
|
231
|
+
const subAppPrefixes = new Map();
|
|
232
|
+
|
|
233
|
+
// 1. Root-level features come first; they keep `metadata.subApp` undefined (root scope).
|
|
234
|
+
for (const f of rootResult && Array.isArray(rootResult.features) ? rootResult.features : []) {
|
|
235
|
+
const norm = String(f.id).toUpperCase().trim();
|
|
236
|
+
seenIds.set(norm, { subApp: null, file: 'FEATURE-MAP.md' });
|
|
237
|
+
merged.push(f);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2. For each sub-app, parse its FEATURE-MAP.md and merge its features.
|
|
241
|
+
for (const target of targets) {
|
|
242
|
+
const subAppRel = target.appPath;
|
|
243
|
+
const subAppName = subAppRel.split('/').pop() || subAppRel;
|
|
244
|
+
// @cap-todo(ac:F-082/iter1 fix:2) Index slug→appPath up-front so the prefix map is populated
|
|
245
|
+
// even for sub-apps with zero features (auto-redirect on a feature that exists in the
|
|
246
|
+
// sub-app but was added to the aggregated cache after this index was built — defensive).
|
|
247
|
+
if (!subAppPrefixes.has(subAppName)) subAppPrefixes.set(subAppName, subAppRel);
|
|
248
|
+
const subFmPath = path.join(projectRoot, subAppRel, FEATURE_MAP_FILE);
|
|
249
|
+
if (!fs.existsSync(subFmPath)) {
|
|
250
|
+
// @cap-todo(ac:F-082/AC-3) Missing sub-app file is warn-and-continue. The Rescoped
|
|
251
|
+
// Table may have been hand-edited or the sub-app deleted before the table was updated.
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
let subContent;
|
|
255
|
+
try {
|
|
256
|
+
subContent = fs.readFileSync(subFmPath, 'utf8');
|
|
257
|
+
} catch (_e) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!subContent || subContent.trim() === '') {
|
|
261
|
+
// Empty sub-app file — treat as zero features, no error.
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
/** @type {import('./cap-feature-map.cjs').FeatureMap} */
|
|
265
|
+
let subResult;
|
|
266
|
+
try {
|
|
267
|
+
// @cap-decision(F-082/iter1 warn:5) Recursion guard is EXPLICIT-by-design: we call
|
|
268
|
+
// `parseFeatureMapContent` (raw-content parser, no I/O, no aggregation) — NOT
|
|
269
|
+
// `readFeatureMap`. The naming difference is the gate. If a future refactor renames
|
|
270
|
+
// or merges these two functions, the recursion-protection contract MUST be re-stated
|
|
271
|
+
// (e.g. via an explicit `_depth` argument or a "no-aggregation" parser flag).
|
|
272
|
+
// @cap-risk(F-082/iter1) Pre-iter1 the gate was implicit (relied on knowing the two
|
|
273
|
+
// functions were different); this comment makes the contract testable at PR review.
|
|
274
|
+
subResult = parseFeatureMapContent(subContent, { projectRoot, safe });
|
|
275
|
+
} catch (e) {
|
|
276
|
+
// strict-mode parser threw — propagate (matches single-map throw contract).
|
|
277
|
+
throw e;
|
|
278
|
+
}
|
|
279
|
+
if (subResult.parseError && !aggParseError) {
|
|
280
|
+
// First sub-app parseError wins; tag with the sub-app file location.
|
|
281
|
+
aggParseError = {
|
|
282
|
+
...subResult.parseError,
|
|
283
|
+
subApp: subAppRel,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (subResult.lastScan && !lastScan) lastScan = subResult.lastScan;
|
|
287
|
+
|
|
288
|
+
const subRel = path.posix.join(subAppRel, FEATURE_MAP_FILE);
|
|
289
|
+
for (const f of subResult.features || []) {
|
|
290
|
+
const norm = String(f.id).toUpperCase().trim();
|
|
291
|
+
if (seenIds.has(norm)) {
|
|
292
|
+
// @cap-todo(ac:F-082/AC-7) Duplicate IDs across aggregated sub-app maps emit a loud,
|
|
293
|
+
// positioned error. No silent dedup — we surface BOTH origins so the user can
|
|
294
|
+
// navigate.
|
|
295
|
+
const first = seenIds.get(norm);
|
|
296
|
+
const message =
|
|
297
|
+
`Duplicate feature ID across aggregated sub-app maps: ${f.id} ` +
|
|
298
|
+
`(in ${subRel}) collides with ${f.id} (in ${first.file})`;
|
|
299
|
+
const dupErr = {
|
|
300
|
+
code: 'CAP_DUPLICATE_FEATURE_ID',
|
|
301
|
+
message,
|
|
302
|
+
duplicateId: norm,
|
|
303
|
+
firstLine: 0,
|
|
304
|
+
duplicateLine: 0,
|
|
305
|
+
firstSubApp: first.subApp,
|
|
306
|
+
duplicateSubApp: subAppName,
|
|
307
|
+
firstFile: first.file,
|
|
308
|
+
duplicateFile: subRel,
|
|
309
|
+
};
|
|
310
|
+
if (safe) {
|
|
311
|
+
if (!aggParseError) aggParseError = dupErr;
|
|
312
|
+
// do NOT push the duplicate; the first-write-wins rule keeps the merged map sane
|
|
313
|
+
// for downstream read-only consumers while parseError signals the conflict.
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const err = new Error(message);
|
|
317
|
+
err.code = 'CAP_DUPLICATE_FEATURE_ID';
|
|
318
|
+
err.duplicateId = norm;
|
|
319
|
+
err.firstSubApp = first.subApp;
|
|
320
|
+
err.duplicateSubApp = subAppName;
|
|
321
|
+
err.firstFile = first.file;
|
|
322
|
+
err.duplicateFile = subRel;
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
seenIds.set(norm, { subApp: subAppName, file: subRel });
|
|
326
|
+
// @cap-todo(ac:F-082/AC-2) Shallow-clone + add runtime-only `metadata.subApp`. Source
|
|
327
|
+
// feature object is never mutated.
|
|
328
|
+
// @cap-decision(F-082/AC-2 + F-081/_inputFormat) Use `metadata.subApp` (not a top-level
|
|
329
|
+
// `_subApp`) for parity with the brainstorm contract. The serializer-side filter strips
|
|
330
|
+
// it before write-back, mirroring the `_inputFormat` runtime-only pattern.
|
|
331
|
+
// @cap-todo(ac:F-082/iter1 fix:3) Deep-clone all array fields. Stage-2 #3 found that the
|
|
332
|
+
// previous spread-only clone left `acs[]`, `files[]`, `dependencies[]`, `usesDesign[]`
|
|
333
|
+
// shared between the aggregated feature and the underlying parsed sub-app feature.
|
|
334
|
+
// Today the writer-filter masks the leak (sub-app features are stripped before serializing
|
|
335
|
+
// the root), but any future code path that exposes the aggregated map without the filter
|
|
336
|
+
// would silently mutate the source sub-app data on push/sort/splice.
|
|
337
|
+
// @cap-decision(F-082/iter1 fix:3) Defense-in-depth at the trust boundary, applied F-076's
|
|
338
|
+
// "do not trust contained-by-convention" lesson. Cost: O(N+ACs) shallow-clones on read,
|
|
339
|
+
// negligible vs. file I/O — N≤200 features for a typical monorepo.
|
|
340
|
+
// @cap-risk(F-082/iter1) AC entries themselves are deep-cloned via `{...a}`. Their fields
|
|
341
|
+
// (id, description, status) are primitives, so shallow object spread is sufficient.
|
|
342
|
+
// If AC schema gains nested objects later, this clone must be widened.
|
|
343
|
+
const cloned = {
|
|
344
|
+
...f,
|
|
345
|
+
acs: Array.isArray(f.acs) ? f.acs.map(a => ({ ...a })) : [],
|
|
346
|
+
files: Array.isArray(f.files) ? [...f.files] : [],
|
|
347
|
+
dependencies: Array.isArray(f.dependencies) ? [...f.dependencies] : [],
|
|
348
|
+
usesDesign: Array.isArray(f.usesDesign) ? [...f.usesDesign] : [],
|
|
349
|
+
metadata: { ...(f.metadata || {}), subApp: subAppName },
|
|
350
|
+
};
|
|
351
|
+
merged.push(cloned);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** @type {import('./cap-feature-map.cjs').FeatureMap} */
|
|
356
|
+
const out = { features: merged, lastScan: lastScan || null };
|
|
357
|
+
if (aggParseError) out.parseError = aggParseError;
|
|
358
|
+
// @cap-todo(ac:F-082/iter1 fix:2) Expose the prefix index. Runtime-only, never persisted —
|
|
359
|
+
// the serializer never iterates underscore-prefixed top-level keys.
|
|
360
|
+
// @cap-todo(ac:F-083/AC-5) `Object.defineProperty` non-enumerable contract preserved verbatim
|
|
361
|
+
// on the F-083 split: the property descriptor is identical to the pre-split definition
|
|
362
|
+
// (enumerable:false, writable:false, configurable:true). Pinned by AC-5 regression test.
|
|
363
|
+
if (subAppPrefixes.size > 0) {
|
|
364
|
+
Object.defineProperty(out, '_subAppPrefixes', {
|
|
365
|
+
value: subAppPrefixes,
|
|
366
|
+
enumerable: false, // Stage-2 lesson — keep enumeration clean for downstream consumers.
|
|
367
|
+
writable: false,
|
|
368
|
+
configurable: true,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// @cap-feature(feature:F-082) extractRescopedBlock — pull the "## Rescoped Feature Maps"
|
|
375
|
+
// section verbatim from existing FEATURE-MAP.md content.
|
|
376
|
+
/**
|
|
377
|
+
* @param {string} content
|
|
378
|
+
* @returns {string|null} - The block text (header line through the line BEFORE the next
|
|
379
|
+
* markdown header) or null if no Rescoped Feature Maps section exists.
|
|
380
|
+
*/
|
|
381
|
+
function extractRescopedBlock(content) {
|
|
382
|
+
if (typeof content !== 'string' || content.length === 0) return null;
|
|
383
|
+
const lines = content.split('\n');
|
|
384
|
+
const headerRE = /^#{2,4}\s+Rescoped\s+Feature\s+Maps\s*$/i;
|
|
385
|
+
let startIdx = -1;
|
|
386
|
+
for (let i = 0; i < lines.length; i++) {
|
|
387
|
+
if (headerRE.test(lines[i])) {
|
|
388
|
+
startIdx = i;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (startIdx === -1) return null;
|
|
393
|
+
// Walk forward to the next markdown header at the same or higher level.
|
|
394
|
+
let endIdx = lines.length;
|
|
395
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
396
|
+
if (/^#{1,6}\s+/.test(lines[i])) {
|
|
397
|
+
endIdx = i;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Trim trailing blank lines from the block.
|
|
402
|
+
while (endIdx > startIdx + 1 && lines[endIdx - 1].trim() === '') endIdx--;
|
|
403
|
+
return lines.slice(startIdx, endIdx).join('\n');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// @cap-feature(feature:F-082) injectRescopedBlock — re-insert the Rescoped Table block into
|
|
407
|
+
// newly-serialized content immediately before the "## Legend" section (or before the
|
|
408
|
+
// trailing footer if Legend is absent).
|
|
409
|
+
/**
|
|
410
|
+
* @param {string} serialized
|
|
411
|
+
* @param {string} block
|
|
412
|
+
* @returns {string}
|
|
413
|
+
*/
|
|
414
|
+
function injectRescopedBlock(serialized, block) {
|
|
415
|
+
const lines = serialized.split('\n');
|
|
416
|
+
// Find the "## Legend" line; insertion point is immediately before it.
|
|
417
|
+
let insertAt = -1;
|
|
418
|
+
for (let i = 0; i < lines.length; i++) {
|
|
419
|
+
if (/^##\s+Legend\s*$/.test(lines[i])) {
|
|
420
|
+
insertAt = i;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (insertAt === -1) {
|
|
425
|
+
// No legend — append before the final footer "---" line if present.
|
|
426
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
427
|
+
if (lines[i].trim() === '---') {
|
|
428
|
+
insertAt = i;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (insertAt === -1) insertAt = lines.length;
|
|
434
|
+
const before = lines.slice(0, insertAt);
|
|
435
|
+
const after = lines.slice(insertAt);
|
|
436
|
+
// Ensure a blank line separates the block from neighbours.
|
|
437
|
+
if (before.length > 0 && before[before.length - 1].trim() !== '') before.push('');
|
|
438
|
+
const blockLines = block.split('\n');
|
|
439
|
+
return [...before, ...blockLines, '', ...after].join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// @cap-feature(feature:F-082) _maybeRedirectToSubApp — internal helper used by all
|
|
443
|
+
// state-mutation functions (updateFeatureState, setAcStatus, setFeatureUsesDesign,
|
|
444
|
+
// transitionWithReason via updateFeatureState). When the looked-up feature lives in a
|
|
445
|
+
// sub-app (`metadata.subApp` set) and the caller did NOT pass appPath, we recurse with
|
|
446
|
+
// the resolved sub-app appPath so the write lands in the correct file. Without this fix,
|
|
447
|
+
// the writer-filter in writeFeatureMap silently drops the feature and the
|
|
448
|
+
// mutation is a no-op.
|
|
449
|
+
// @cap-decision(F-082/iter1 fix:2) Sentinel-based control flow (`_NO_REDIRECT`) keeps the
|
|
450
|
+
// helper composable: callers can compare the return against the sentinel to know whether
|
|
451
|
+
// the redirect ran (use the result directly) or did not (fall through to the legacy code
|
|
452
|
+
// path). Returning `null`/`undefined` would conflict with legitimate boolean return values
|
|
453
|
+
// from the original mutation functions.
|
|
454
|
+
// @cap-risk(F-082/iter1) Recursion-loop guard: only triggers when `appPath` is null/undefined,
|
|
455
|
+
// AND the recursion always passes a resolved appPath, so the recursive call cannot re-enter
|
|
456
|
+
// this branch. F-077 lesson on infinite-loop guards applied.
|
|
457
|
+
// @cap-decision(F-083/cycle) `_NO_REDIRECT` symbol lives HERE, not in cap-feature-map.cjs.
|
|
458
|
+
// Reason: it's only meaningful in the redirect protocol owned by this module. Core re-exports
|
|
459
|
+
// it for backward compat (callers in updateFeatureState/setAcStatus/setFeatureUsesDesign
|
|
460
|
+
// compare against it via the lazy-loaded reference).
|
|
461
|
+
const _NO_REDIRECT = Symbol('cap-feature-map._NO_REDIRECT');
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {string} projectRoot
|
|
465
|
+
* @param {import('./cap-feature-map.cjs').FeatureMap} featureMap - aggregated map (carries `_subAppPrefixes`)
|
|
466
|
+
* @param {import('./cap-feature-map.cjs').Feature} feature - looked-up feature
|
|
467
|
+
* @param {string|null|undefined} appPath - caller-supplied app path
|
|
468
|
+
* @param {string} fnName - calling function name for warn message
|
|
469
|
+
* @param {(resolvedAppPath: string) => any} recurse - bound recursion into the same fn
|
|
470
|
+
* @returns {any} - either the recursed result or the `_NO_REDIRECT` sentinel
|
|
471
|
+
*/
|
|
472
|
+
function _maybeRedirectToSubApp(projectRoot, featureMap, feature, appPath, fnName, recurse) {
|
|
473
|
+
// @cap-todo(ac:F-083/AC-6) Lazy require for _safeForError — keeps the cycle dormant.
|
|
474
|
+
const { _safeForError } = _core();
|
|
475
|
+
// Caller already supplied appPath — never redirect (would loop or override caller intent).
|
|
476
|
+
if (appPath) return _NO_REDIRECT;
|
|
477
|
+
// Feature is root-direct — legacy path is correct.
|
|
478
|
+
if (!(feature && feature.metadata && feature.metadata.subApp)) return _NO_REDIRECT;
|
|
479
|
+
|
|
480
|
+
const subApp = feature.metadata.subApp;
|
|
481
|
+
const prefixes = featureMap && featureMap._subAppPrefixes;
|
|
482
|
+
const resolvedAppPath = prefixes && typeof prefixes.get === 'function' ? prefixes.get(subApp) : null;
|
|
483
|
+
if (resolvedAppPath) {
|
|
484
|
+
// @cap-todo(ac:F-082/iter1 fix:2) Auto-redirect via the prefix map populated by the aggregator.
|
|
485
|
+
return recurse(resolvedAppPath);
|
|
486
|
+
}
|
|
487
|
+
// No prefix resolution available → loud structured rejection (defense-in-depth path).
|
|
488
|
+
// @cap-decision(F-082/followup) ANSI-defense: subApp is a path-derived slug from
|
|
489
|
+
// metadata.subApp; while the parser's slug-regex rejects controls today, we still wrap
|
|
490
|
+
// in _safeForError as defense-in-depth (mirrors F-076/F-081 doctrine).
|
|
491
|
+
console.warn(
|
|
492
|
+
'cap: ' + _safeForError(fnName) + '("' + _safeForError(feature.id) +
|
|
493
|
+
'") skipped — feature lives in sub-app "' + _safeForError(subApp) +
|
|
494
|
+
'" but no sub-app path could be resolved; pass appPath explicitly to persist.'
|
|
495
|
+
);
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// @cap-feature(feature:F-082) _enrichFromTagsAcrossSubApps — internal monorepo split.
|
|
500
|
+
// Groups features by `metadata.subApp` and runs enrichment per scope (root + each sub-app),
|
|
501
|
+
// re-reading + re-writing each scope's FEATURE-MAP.md independently. Stage-2 #1 fix.
|
|
502
|
+
// @cap-decision(F-082/iter1 fix:1) Re-read each sub-app via `readFeatureMap(root, appPath)` so
|
|
503
|
+
// the per-scope mutation operates on a single-map view (no aggregation, no writer-filter
|
|
504
|
+
// surprises). The aggregated map is used only as the index of which features live where.
|
|
505
|
+
// @cap-todo(ac:F-083/AC-1) Exported from this module as part of the F-083 split surface.
|
|
506
|
+
/**
|
|
507
|
+
* @param {string} projectRoot
|
|
508
|
+
* @param {import('./cap-tag-scanner.cjs').CapTag[]} scanResults
|
|
509
|
+
* @param {import('./cap-feature-map.cjs').FeatureMap} aggregatedMap - aggregated map carrying `_subAppPrefixes` and
|
|
510
|
+
* `metadata.subApp` per feature
|
|
511
|
+
* @returns {import('./cap-feature-map.cjs').FeatureMap} - the aggregated map (re-read post-write so callers see fresh state)
|
|
512
|
+
*/
|
|
513
|
+
function _enrichFromTagsAcrossSubApps(projectRoot, scanResults, aggregatedMap) {
|
|
514
|
+
// @cap-todo(ac:F-083/AC-6) Lazy require — see _core() above.
|
|
515
|
+
const { readFeatureMap, writeFeatureMap, _safeForError } = _core();
|
|
516
|
+
// Group features by subApp slug (null = root-direct).
|
|
517
|
+
/** @type {Map<string|null, Set<string>>} */
|
|
518
|
+
const featureIdsByScope = new Map();
|
|
519
|
+
featureIdsByScope.set(null, new Set());
|
|
520
|
+
for (const f of aggregatedMap.features || []) {
|
|
521
|
+
const scope = (f.metadata && f.metadata.subApp) || null;
|
|
522
|
+
if (!featureIdsByScope.has(scope)) featureIdsByScope.set(scope, new Set());
|
|
523
|
+
featureIdsByScope.get(scope).add(f.id);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const prefixes = aggregatedMap._subAppPrefixes;
|
|
527
|
+
|
|
528
|
+
// @cap-decision(F-082/followup) Best-effort batch-write logging. Per-scope writes are NOT
|
|
529
|
+
// atomic across the N sub-app maps (true 2-phase commit across N files is out of scope).
|
|
530
|
+
// We track which scopes wrote successfully and which failed, then emit a single summary
|
|
531
|
+
// warn after the loop so partial-write is never silent. The per-scope try/catch keeps a
|
|
532
|
+
// late EROFS / EACCES from aborting writes for healthy sibling scopes.
|
|
533
|
+
/** @type {string[]} */ const written = [];
|
|
534
|
+
/** @type {{scope: string, error: string}[]} */ const failed = [];
|
|
535
|
+
|
|
536
|
+
// For each scope, perform a single-map enrichment + write.
|
|
537
|
+
for (const [scope, idsInScope] of featureIdsByScope) {
|
|
538
|
+
if (idsInScope.size === 0) continue;
|
|
539
|
+
const scopedAppPath = scope ? (prefixes ? prefixes.get(scope) : null) : null;
|
|
540
|
+
if (scope && !scopedAppPath) {
|
|
541
|
+
// Sub-app slug present but prefix could not be resolved — defensive skip.
|
|
542
|
+
// @cap-decision(F-082/followup) ANSI-defense via _safeForError on user-controlled scope slug.
|
|
543
|
+
console.warn('cap: enrichFromTags — sub-app "' + _safeForError(scope) + '" prefix unresolved; tags for that scope skipped.');
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const scopedMap = readFeatureMap(projectRoot, scopedAppPath || undefined, { safe: true });
|
|
547
|
+
if (scopedMap.parseError) {
|
|
548
|
+
// @cap-decision(F-082/followup) ANSI-defense via _safeForError on scope + parseError.message.
|
|
549
|
+
console.warn('cap: enrichFromTags — skipping scope "' + _safeForError(scope || 'root') + '": ' + _safeForError(scopedMap.parseError.message));
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
let mutated = false;
|
|
553
|
+
for (const tag of scanResults) {
|
|
554
|
+
if (tag.type !== 'feature') continue;
|
|
555
|
+
const featureId = tag.metadata.feature;
|
|
556
|
+
if (!featureId) continue;
|
|
557
|
+
if (!idsInScope.has(featureId)) continue; // feature lives in a different scope
|
|
558
|
+
const feature = scopedMap.features.find(f => f.id === featureId);
|
|
559
|
+
if (!feature) continue;
|
|
560
|
+
if (!feature.files.includes(tag.file)) {
|
|
561
|
+
feature.files.push(tag.file);
|
|
562
|
+
mutated = true;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (mutated) {
|
|
566
|
+
const scopeLabel = scope || 'root';
|
|
567
|
+
try {
|
|
568
|
+
writeFeatureMap(projectRoot, scopedMap, scopedAppPath || undefined);
|
|
569
|
+
written.push(scopeLabel);
|
|
570
|
+
} catch (e) {
|
|
571
|
+
failed.push({ scope: scopeLabel, error: (e && e.message) ? e.message : String(e) });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// @cap-decision(F-082/followup) Summary warn fires only when at least one scope FAILED;
|
|
577
|
+
// keeps the happy-path silent. Includes both the failed and the (still-)written scopes
|
|
578
|
+
// so the user has actionable diagnostics — they know exactly which FEATURE-MAP files
|
|
579
|
+
// did and did not get the new file refs.
|
|
580
|
+
if (failed.length > 0) {
|
|
581
|
+
const failedSummary = failed.map(f => '"' + _safeForError(f.scope) + '" (' + _safeForError(f.error) + ')').join(', ');
|
|
582
|
+
const writtenSummary = written.length > 0 ? written.map(s => '"' + _safeForError(s) + '"').join(', ') : '(none)';
|
|
583
|
+
console.warn(
|
|
584
|
+
'cap: enrichFromTags — partial write: ' + failed.length + ' scope(s) failed: ' + failedSummary +
|
|
585
|
+
'; ' + written.length + ' scope(s) written: ' + writtenSummary
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Return a fresh aggregated read so callers see the post-write state.
|
|
590
|
+
return readFeatureMap(projectRoot, undefined, { safe: true });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// @cap-feature(feature:F-082) _enrichFromDesignTagsAcrossSubApps — monorepo split for design tags.
|
|
594
|
+
// Same lesson + structure as _enrichFromTagsAcrossSubApps. The file→featureId index is built
|
|
595
|
+
// once from the aggregated map, then per-scope writes apply only the design IDs whose owning
|
|
596
|
+
// feature lives in that scope.
|
|
597
|
+
// @cap-todo(ac:F-083/AC-1) Exported from this module as part of the F-083 split surface.
|
|
598
|
+
/**
|
|
599
|
+
* @param {string} projectRoot
|
|
600
|
+
* @param {import('./cap-tag-scanner.cjs').CapTag[]} scanResults
|
|
601
|
+
* @param {import('./cap-feature-map.cjs').FeatureMap} aggregatedMap
|
|
602
|
+
* @returns {import('./cap-feature-map.cjs').FeatureMap}
|
|
603
|
+
*/
|
|
604
|
+
function _enrichFromDesignTagsAcrossSubApps(projectRoot, scanResults, aggregatedMap) {
|
|
605
|
+
// @cap-todo(ac:F-083/AC-6) Lazy require — see _core() above.
|
|
606
|
+
const { readFeatureMap, writeFeatureMap, _safeForError } = _core();
|
|
607
|
+
/** @type {Map<string|null, Set<string>>} */
|
|
608
|
+
const featureIdsByScope = new Map();
|
|
609
|
+
featureIdsByScope.set(null, new Set());
|
|
610
|
+
for (const f of aggregatedMap.features || []) {
|
|
611
|
+
const scope = (f.metadata && f.metadata.subApp) || null;
|
|
612
|
+
if (!featureIdsByScope.has(scope)) featureIdsByScope.set(scope, new Set());
|
|
613
|
+
featureIdsByScope.get(scope).add(f.id);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// file→featureId index (matches the legacy single-scope behavior).
|
|
617
|
+
const fileToFeature = new Map();
|
|
618
|
+
for (const tag of scanResults) {
|
|
619
|
+
if (tag.type !== 'feature') continue;
|
|
620
|
+
const fid = tag.metadata && tag.metadata.feature;
|
|
621
|
+
if (!fid) continue;
|
|
622
|
+
if (!fileToFeature.has(tag.file)) fileToFeature.set(tag.file, fid);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const prefixes = aggregatedMap._subAppPrefixes;
|
|
626
|
+
|
|
627
|
+
// @cap-decision(F-082/followup) Best-effort batch-write logging — see
|
|
628
|
+
// _enrichFromTagsAcrossSubApps for the full reasoning.
|
|
629
|
+
/** @type {string[]} */ const written = [];
|
|
630
|
+
/** @type {{scope: string, error: string}[]} */ const failed = [];
|
|
631
|
+
|
|
632
|
+
for (const [scope, idsInScope] of featureIdsByScope) {
|
|
633
|
+
if (idsInScope.size === 0) continue;
|
|
634
|
+
const scopedAppPath = scope ? (prefixes ? prefixes.get(scope) : null) : null;
|
|
635
|
+
if (scope && !scopedAppPath) {
|
|
636
|
+
// @cap-decision(F-082/followup) ANSI-defense via _safeForError on user-controlled scope slug.
|
|
637
|
+
console.warn('cap: enrichFromDesignTags — sub-app "' + _safeForError(scope) + '" prefix unresolved; design tags for that scope skipped.');
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const scopedMap = readFeatureMap(projectRoot, scopedAppPath || undefined, { safe: true });
|
|
641
|
+
if (scopedMap.parseError) {
|
|
642
|
+
// @cap-decision(F-082/followup) ANSI-defense via _safeForError on scope + parseError.message.
|
|
643
|
+
console.warn('cap: enrichFromDesignTags — skipping scope "' + _safeForError(scope || 'root') + '": ' + _safeForError(scopedMap.parseError.message));
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
let mutated = false;
|
|
647
|
+
for (const tag of scanResults) {
|
|
648
|
+
if (tag.type !== 'design-token' && tag.type !== 'design-component') continue;
|
|
649
|
+
const designId = tag.metadata && tag.metadata.id;
|
|
650
|
+
if (!designId) continue;
|
|
651
|
+
const featureId = fileToFeature.get(tag.file);
|
|
652
|
+
if (!featureId) continue;
|
|
653
|
+
if (!idsInScope.has(featureId)) continue;
|
|
654
|
+
const feature = scopedMap.features.find(f => f.id === featureId);
|
|
655
|
+
if (!feature) continue;
|
|
656
|
+
if (!Array.isArray(feature.usesDesign)) feature.usesDesign = [];
|
|
657
|
+
if (!feature.usesDesign.includes(designId)) {
|
|
658
|
+
feature.usesDesign.push(designId);
|
|
659
|
+
mutated = true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (mutated) {
|
|
663
|
+
for (const f of scopedMap.features) {
|
|
664
|
+
if (Array.isArray(f.usesDesign)) f.usesDesign.sort();
|
|
665
|
+
}
|
|
666
|
+
const scopeLabel = scope || 'root';
|
|
667
|
+
try {
|
|
668
|
+
writeFeatureMap(projectRoot, scopedMap, scopedAppPath || undefined);
|
|
669
|
+
written.push(scopeLabel);
|
|
670
|
+
} catch (e) {
|
|
671
|
+
failed.push({ scope: scopeLabel, error: (e && e.message) ? e.message : String(e) });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// @cap-decision(F-082/followup) Partial-write summary mirrors _enrichFromTagsAcrossSubApps.
|
|
677
|
+
if (failed.length > 0) {
|
|
678
|
+
const failedSummary = failed.map(f => '"' + _safeForError(f.scope) + '" (' + _safeForError(f.error) + ')').join(', ');
|
|
679
|
+
const writtenSummary = written.length > 0 ? written.map(s => '"' + _safeForError(s) + '"').join(', ') : '(none)';
|
|
680
|
+
console.warn(
|
|
681
|
+
'cap: enrichFromDesignTags — partial write: ' + failed.length + ' scope(s) failed: ' + failedSummary +
|
|
682
|
+
'; ' + written.length + ' scope(s) written: ' + writtenSummary
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return readFeatureMap(projectRoot, undefined, { safe: true });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// @cap-api initAppFeatureMap(projectRoot, appPath) -- Create FEATURE-MAP.md for a specific app in a monorepo.
|
|
690
|
+
// Idempotent: does not overwrite existing FEATURE-MAP.md.
|
|
691
|
+
// @cap-decision(F-083/balance) Moved to monorepo module — sub-app FEATURE-MAP creation is a
|
|
692
|
+
// monorepo-only concern; nothing in single-scope mode triggers it.
|
|
693
|
+
/**
|
|
694
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
695
|
+
* @param {string} appPath - Relative app path (e.g., "apps/flow")
|
|
696
|
+
* @returns {boolean} - True if created, false if already existed
|
|
697
|
+
*/
|
|
698
|
+
function initAppFeatureMap(projectRoot, appPath) {
|
|
699
|
+
// @cap-todo(ac:F-083/AC-6) Lazy require — see _core() above.
|
|
700
|
+
const { generateTemplate } = _core();
|
|
701
|
+
const baseDir = path.join(projectRoot, appPath);
|
|
702
|
+
const filePath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
703
|
+
if (fs.existsSync(filePath)) return false;
|
|
704
|
+
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
|
|
705
|
+
fs.writeFileSync(filePath, generateTemplate(), 'utf8');
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// @cap-api listAppFeatureMaps(projectRoot) -- Find all FEATURE-MAP.md files in a monorepo.
|
|
710
|
+
// Returns array of relative paths to directories containing FEATURE-MAP.md.
|
|
711
|
+
// @cap-decision(F-083/balance) Moved to monorepo module — pure monorepo discovery utility.
|
|
712
|
+
/**
|
|
713
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
714
|
+
* @returns {string[]} - Relative directory paths that contain FEATURE-MAP.md (e.g., [".", "apps/flow", "packages/ui"])
|
|
715
|
+
*/
|
|
716
|
+
function listAppFeatureMaps(projectRoot) {
|
|
717
|
+
const results = [];
|
|
718
|
+
if (fs.existsSync(path.join(projectRoot, FEATURE_MAP_FILE))) results.push('.');
|
|
719
|
+
const excludeDirs = new Set(['node_modules', '.git', '.cap', 'dist', 'build', 'coverage', '.planning']);
|
|
720
|
+
|
|
721
|
+
function walk(dir, depth) {
|
|
722
|
+
if (depth > 3) return;
|
|
723
|
+
let entries;
|
|
724
|
+
try {
|
|
725
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
726
|
+
} catch (_e) { return; }
|
|
727
|
+
for (const entry of entries) {
|
|
728
|
+
if (!entry.isDirectory()) continue;
|
|
729
|
+
if (excludeDirs.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
730
|
+
const fullPath = path.join(dir, entry.name);
|
|
731
|
+
const fmPath = path.join(fullPath, FEATURE_MAP_FILE);
|
|
732
|
+
if (fs.existsSync(fmPath)) results.push(path.relative(projectRoot, fullPath));
|
|
733
|
+
walk(fullPath, depth + 1);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
walk(projectRoot, 0);
|
|
737
|
+
return results;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// @cap-feature(feature:F-082) rescopeFeatures — write-side counterpart to parseRescopedTable.
|
|
741
|
+
// @cap-decision(F-083/balance) Moved to monorepo module — distributing root features into
|
|
742
|
+
// per-app FEATURE-MAP.md files is by-definition a monorepo operation. The function uses
|
|
743
|
+
// readFeatureMap and writeFeatureMap, both lazy-required from core.
|
|
744
|
+
/**
|
|
745
|
+
* Rescope a root FEATURE-MAP.md into per-app Feature Maps in a monorepo.
|
|
746
|
+
* Distributes features to apps based on file references (feature.files paths).
|
|
747
|
+
* Features with no file refs or cross-app refs stay at root.
|
|
748
|
+
*
|
|
749
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
750
|
+
* @param {string[]} appPaths - List of app relative paths (e.g., ["apps/flow", "apps/hub"])
|
|
751
|
+
* @param {Object} [options]
|
|
752
|
+
* @param {boolean} [options.dryRun] - If true, report changes without writing
|
|
753
|
+
* @returns {{ appsCreated: number, featuresDistributed: number, featuresKeptAtRoot: number, distribution: Object }}
|
|
754
|
+
*/
|
|
755
|
+
function rescopeFeatures(projectRoot, appPaths, options = {}) {
|
|
756
|
+
// @cap-todo(ac:F-083/AC-6) Lazy require — see _core() above.
|
|
757
|
+
const { readFeatureMap, writeFeatureMap } = _core();
|
|
758
|
+
// @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
|
|
759
|
+
// @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
|
|
760
|
+
const rootMap = readFeatureMap(projectRoot, undefined, { safe: true });
|
|
761
|
+
if (rootMap.parseError) {
|
|
762
|
+
console.warn('cap: rescopeFeatures aborted — duplicate feature ID detected: ' + String(rootMap.parseError.message).trim());
|
|
763
|
+
return { appsCreated: 0, featuresDistributed: 0, featuresKeptAtRoot: 0, distribution: {}, parseError: rootMap.parseError };
|
|
764
|
+
}
|
|
765
|
+
if (!rootMap.features || rootMap.features.length === 0) {
|
|
766
|
+
return { appsCreated: 0, featuresDistributed: 0, featuresKeptAtRoot: 0, distribution: {} };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Build distribution: which features belong to which app
|
|
770
|
+
const distribution = {}; // appPath -> features[]
|
|
771
|
+
const rootFeatures = []; // features that stay at root (no refs or cross-app)
|
|
772
|
+
|
|
773
|
+
for (const feature of rootMap.features) {
|
|
774
|
+
if (!feature.files || feature.files.length === 0) {
|
|
775
|
+
rootFeatures.push(feature);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// Determine which app this feature belongs to based on file paths
|
|
779
|
+
const appCounts = {}; // appPath -> count of matching files
|
|
780
|
+
for (const file of feature.files) {
|
|
781
|
+
for (const appPath of appPaths) {
|
|
782
|
+
if (file.startsWith(appPath + '/') || file.startsWith(appPath + path.sep)) {
|
|
783
|
+
appCounts[appPath] = (appCounts[appPath] || 0) + 1;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const entries = Object.entries(appCounts);
|
|
788
|
+
if (entries.length === 0) {
|
|
789
|
+
rootFeatures.push(feature);
|
|
790
|
+
} else if (entries.length === 1) {
|
|
791
|
+
const [appPath] = entries[0];
|
|
792
|
+
if (!distribution[appPath]) distribution[appPath] = [];
|
|
793
|
+
distribution[appPath].push(feature);
|
|
794
|
+
} else {
|
|
795
|
+
entries.sort((a, b) => b[1] - a[1]);
|
|
796
|
+
const primaryApp = entries[0][0];
|
|
797
|
+
if (!distribution[primaryApp]) distribution[primaryApp] = [];
|
|
798
|
+
distribution[primaryApp].push(feature);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (options.dryRun) {
|
|
803
|
+
let totalDistributed = 0;
|
|
804
|
+
for (const features of Object.values(distribution)) totalDistributed += features.length;
|
|
805
|
+
return {
|
|
806
|
+
appsCreated: Object.keys(distribution).length,
|
|
807
|
+
featuresDistributed: totalDistributed,
|
|
808
|
+
featuresKeptAtRoot: rootFeatures.length,
|
|
809
|
+
distribution: Object.fromEntries(
|
|
810
|
+
Object.entries(distribution).map(([app, features]) => [app, features.map(f => f.id)])
|
|
811
|
+
),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Write per-app Feature Maps
|
|
816
|
+
let appsCreated = 0;
|
|
817
|
+
let featuresDistributed = 0;
|
|
818
|
+
|
|
819
|
+
for (const [appPath, features] of Object.entries(distribution)) {
|
|
820
|
+
const appDir = path.join(projectRoot, appPath);
|
|
821
|
+
if (!fs.existsSync(appDir)) continue;
|
|
822
|
+
|
|
823
|
+
// @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
|
|
824
|
+
const existingMap = readFeatureMap(projectRoot, appPath, { safe: true });
|
|
825
|
+
if (existingMap.parseError) {
|
|
826
|
+
console.warn('cap: rescopeFeatures skipping app "' + appPath + '" — duplicate feature ID detected: ' + String(existingMap.parseError.message).trim());
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const existingIds = new Set(existingMap.features.map(f => f.id));
|
|
830
|
+
|
|
831
|
+
// Re-number features for the app (F-001, F-002, ...)
|
|
832
|
+
let nextId = existingMap.features.length + 1;
|
|
833
|
+
for (const feature of features) {
|
|
834
|
+
if (existingIds.has(feature.id)) continue;
|
|
835
|
+
const appRelativeFiles = feature.files
|
|
836
|
+
.filter(f => f.startsWith(appPath + '/'))
|
|
837
|
+
.map(f => f.slice(appPath.length + 1));
|
|
838
|
+
const otherFiles = feature.files.filter(f => !f.startsWith(appPath + '/'));
|
|
839
|
+
const appFeature = {
|
|
840
|
+
...feature,
|
|
841
|
+
id: `F-${String(nextId).padStart(3, '0')}`,
|
|
842
|
+
files: [...appRelativeFiles, ...otherFiles],
|
|
843
|
+
metadata: { ...feature.metadata, originalId: feature.id },
|
|
844
|
+
};
|
|
845
|
+
existingMap.features.push(appFeature);
|
|
846
|
+
nextId++;
|
|
847
|
+
featuresDistributed++;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
writeFeatureMap(projectRoot, existingMap, appPath);
|
|
851
|
+
appsCreated++;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Rewrite root Feature Map with only root features
|
|
855
|
+
const newRootMap = { features: rootFeatures, lastScan: rootMap.lastScan };
|
|
856
|
+
writeFeatureMap(projectRoot, newRootMap);
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
appsCreated,
|
|
860
|
+
featuresDistributed,
|
|
861
|
+
featuresKeptAtRoot: rootFeatures.length,
|
|
862
|
+
distribution: Object.fromEntries(
|
|
863
|
+
Object.entries(distribution).map(([app, features]) => [app, features.map(f => f.id)])
|
|
864
|
+
),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
module.exports = {
|
|
869
|
+
// F-083/AC-1 — exact six-name surface called out in the AC plus their helpers.
|
|
870
|
+
parseRescopedTable,
|
|
871
|
+
discoverSubAppFeatureMaps,
|
|
872
|
+
aggregateSubAppFeatureMaps,
|
|
873
|
+
_enrichFromTagsAcrossSubApps,
|
|
874
|
+
_enrichFromDesignTagsAcrossSubApps,
|
|
875
|
+
_maybeRedirectToSubApp,
|
|
876
|
+
// F-082 helpers used by writeFeatureMap (lazy-loaded from cap-feature-map.cjs).
|
|
877
|
+
extractRescopedBlock,
|
|
878
|
+
injectRescopedBlock,
|
|
879
|
+
// F-082 sentinel — owned by the redirect protocol; re-exported by core for back-compat.
|
|
880
|
+
_NO_REDIRECT,
|
|
881
|
+
// F-083/balance — additional monorepo helpers moved out of core to land core under 1500 LOC.
|
|
882
|
+
initAppFeatureMap,
|
|
883
|
+
listAppFeatureMaps,
|
|
884
|
+
rescopeFeatures,
|
|
885
|
+
};
|