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,398 @@
|
|
|
1
|
+
// @cap-feature(feature:F-078, primary:true) Extends-Chain Resolver — resolves
|
|
2
|
+
// `extends: platform/<topic>` chains across per-feature memory files in a single lookup pass,
|
|
3
|
+
// with explicit cycle detection.
|
|
4
|
+
//
|
|
5
|
+
// @cap-context Per-Feature memory files (F-076) carry an optional `extends: platform/<topic>`
|
|
6
|
+
// frontmatter field (defined in cap-memory-schema.cjs:EXTENDS_RE). F-078/AC-3 says the reader
|
|
7
|
+
// MUST resolve those chains in a SINGLE pass — no recursive expansion that could blow up on
|
|
8
|
+
// pathological input, no per-feature partial-merge that could shadow upstream errors.
|
|
9
|
+
//
|
|
10
|
+
// @cap-context F-078/AC-5 says cycles MUST be rejected with the FULL chain in the error message
|
|
11
|
+
// (e.g. `F-070 → platform/A → platform/B → platform/A`), not a generic "cycle detected".
|
|
12
|
+
// That's testable in the error-string assertion, and it's the difference between a 30-second
|
|
13
|
+
// fix and an hour of debugging.
|
|
14
|
+
//
|
|
15
|
+
// @cap-decision(F-078/AC-3) Single-pass resolution: walk the extends chain iteratively with a
|
|
16
|
+
// visited-set, accumulating layers in order. Depth-bound at MAX_CHAIN_DEPTH (8) as a hard
|
|
17
|
+
// safety net — even if the cycle detector somehow missed a cycle, the depth cap fails loud
|
|
18
|
+
// instead of looping. This is defense-in-depth, not the primary detector.
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const schema = require('./cap-memory-schema.cjs');
|
|
25
|
+
const platformLib = require('./cap-memory-platform.cjs');
|
|
26
|
+
|
|
27
|
+
// -------- Constants --------
|
|
28
|
+
|
|
29
|
+
// @cap-decision(F-078/D8) Hard cap on chain depth — any project that legitimately needs >8
|
|
30
|
+
// levels of platform extends has bigger problems than this resolver. The cap exists so a
|
|
31
|
+
// hostile input (a cycle that the visited-set somehow missed) can't loop forever.
|
|
32
|
+
const MAX_CHAIN_DEPTH = 8;
|
|
33
|
+
|
|
34
|
+
// @cap-decision(F-078/iter1) Stage-2 #1 fix: ANSI defense extended to extends-resolver.
|
|
35
|
+
// User-controlled bytes (extendsRef from frontmatter, ref strings shown in cycle-paths and
|
|
36
|
+
// dangling-warnings) flow into error/warning messages that are typically piped to a terminal.
|
|
37
|
+
// Without sanitization, an attacker-authored memory file containing ANSI escape bytes could
|
|
38
|
+
// recolor or truncate operator-visible output. We mirror the helper from cap-memory-platform.cjs
|
|
39
|
+
// rather than importing it: keeping the defense local to each module avoids a fragile coupling
|
|
40
|
+
// where someone refactors platform's helper out from under us. Both modules share the SAME
|
|
41
|
+
// behavior — strip non-printable bytes outside `0x20-0x7E`, slice to 64 chars.
|
|
42
|
+
function _safeForError(value) {
|
|
43
|
+
if (typeof value !== 'string') return String(value);
|
|
44
|
+
return value.replace(/[^\x20-\x7E]/g, '?').slice(0, 64);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// @cap-decision(F-078/iter1) Stage-2 #2 fix helper: deep-clone YAML-derived frontmatter
|
|
48
|
+
// while preserving null-prototype on the returned object. JSON-roundtrip handles the
|
|
49
|
+
// nested arrays/objects (frontmatter is plain-data only — no Date, RegExp, Map, etc.),
|
|
50
|
+
// then we re-create with `Object.create(null)` to keep the proto-pollution defense.
|
|
51
|
+
function _deepCloneFrontmatter(src) {
|
|
52
|
+
if (!src || typeof src !== 'object') return Object.create(null);
|
|
53
|
+
let cloned;
|
|
54
|
+
try {
|
|
55
|
+
cloned = JSON.parse(JSON.stringify(src));
|
|
56
|
+
} catch (_e) {
|
|
57
|
+
cloned = {};
|
|
58
|
+
}
|
|
59
|
+
const out = Object.create(null);
|
|
60
|
+
for (const k of Object.keys(cloned)) {
|
|
61
|
+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
|
|
62
|
+
out[k] = cloned[k];
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -------- Typedefs --------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {Object} ExtendsLayer
|
|
71
|
+
* @property {'feature'|'platform'} kind
|
|
72
|
+
* @property {string} ref - "F-NNN" or "platform/<topic>"
|
|
73
|
+
* @property {string} path - filesystem path the layer was loaded from
|
|
74
|
+
* @property {boolean} exists - true if the file was found and loaded
|
|
75
|
+
* @property {import('./cap-memory-schema.cjs').FeatureMemoryFile|null} file - parsed file, or null if missing
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {Object} ResolveResult
|
|
80
|
+
* @property {boolean} ok - true if resolution succeeded; false if a cycle was detected
|
|
81
|
+
* @property {ExtendsLayer[]} layers - ordered chain of resolved layers; first = root, last = deepest extends
|
|
82
|
+
* @property {string[]} chain - human-readable chain of refs (e.g. ["F-070", "platform/A", "platform/B"])
|
|
83
|
+
* @property {string[]} warnings - non-fatal warnings (e.g. dangling extends)
|
|
84
|
+
* @property {string|null} error - cycle path or other fatal error, null on success
|
|
85
|
+
* @property {string|null} cyclePath - "F-070 → platform/A → platform/A" formatted chain, null if no cycle
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
// -------- Reference helpers --------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse an extends-ref string into kind + ref components.
|
|
92
|
+
* Currently only `platform/<topic>` is supported (mirrors EXTENDS_RE in the schema).
|
|
93
|
+
* @param {string} ref
|
|
94
|
+
* @returns {{kind:'platform', topic:string}|null}
|
|
95
|
+
*/
|
|
96
|
+
function parseExtendsRef(ref) {
|
|
97
|
+
if (typeof ref !== 'string') return null;
|
|
98
|
+
const m = ref.match(/^platform\/([a-z0-9]+(?:-[a-z0-9]+)*)$/);
|
|
99
|
+
if (!m) return null;
|
|
100
|
+
return { kind: 'platform', topic: m[1] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a stable visited-set key for a layer ref (used in cycle detection).
|
|
105
|
+
* @param {string} ref
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
function _refKey(ref) {
|
|
109
|
+
return ref;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -------- Loaders --------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Load the layer at `extendsRef` (currently always platform). Returns the layer record
|
|
116
|
+
* regardless of whether the underlying file exists — caller decides whether to treat
|
|
117
|
+
* a dangling extends as fatal (we don't; we soft-warn per F-078 spec gap).
|
|
118
|
+
*
|
|
119
|
+
* @param {string} projectRoot
|
|
120
|
+
* @param {string} extendsRef - e.g. "platform/observability"
|
|
121
|
+
* @returns {ExtendsLayer}
|
|
122
|
+
*/
|
|
123
|
+
function loadLayer(projectRoot, extendsRef) {
|
|
124
|
+
const parsed = parseExtendsRef(extendsRef);
|
|
125
|
+
if (!parsed) {
|
|
126
|
+
// Caller has already validated the ref shape via the schema's EXTENDS_RE, so this is a
|
|
127
|
+
// defensive fallback: return a layer with exists=false so the resolver can warn cleanly.
|
|
128
|
+
return {
|
|
129
|
+
kind: 'platform',
|
|
130
|
+
ref: extendsRef,
|
|
131
|
+
path: '',
|
|
132
|
+
exists: false,
|
|
133
|
+
file: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const loaded = platformLib.loadPlatformTopic(projectRoot, parsed.topic);
|
|
137
|
+
return {
|
|
138
|
+
kind: 'platform',
|
|
139
|
+
ref: extendsRef,
|
|
140
|
+
path: loaded.path,
|
|
141
|
+
exists: loaded.exists,
|
|
142
|
+
file: loaded.file,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -------- Core resolver --------
|
|
147
|
+
|
|
148
|
+
// @cap-todo(ac:F-078/AC-3) resolveExtends walks the extends-chain in a SINGLE pass and returns
|
|
149
|
+
// the ordered layer list. Per-feature file is layer[0]; each platform extends is appended.
|
|
150
|
+
// @cap-todo(ac:F-078/AC-5) resolveExtends detects cycles via a visited-set keyed on the
|
|
151
|
+
// normalized ref string. Cycle path is rendered with `→` separators so the error message
|
|
152
|
+
// contains the FULL chain, not just "cycle detected".
|
|
153
|
+
// @cap-risk(reason:cycle-mishandling-corrupts-resolved-view) If the visited-set check fires
|
|
154
|
+
// AFTER pushing the layer (not before), the cycle path would be off-by-one and could leak
|
|
155
|
+
// the duplicate entry into the merged view. Order matters: check FIRST, then push.
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve a per-feature memory file's extends chain into an ordered layer list.
|
|
159
|
+
* Pure-ish: reads files via cap-memory-platform's loaders, but does not write.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} projectRoot
|
|
162
|
+
* @param {string} perFeaturePath - absolute path to a .cap/memory/features/F-NNN-<topic>.md file
|
|
163
|
+
* @returns {ResolveResult}
|
|
164
|
+
*/
|
|
165
|
+
function resolveExtends(projectRoot, perFeaturePath) {
|
|
166
|
+
/** @type {ResolveResult} */
|
|
167
|
+
const result = {
|
|
168
|
+
ok: true,
|
|
169
|
+
layers: [],
|
|
170
|
+
chain: [],
|
|
171
|
+
warnings: [],
|
|
172
|
+
error: null,
|
|
173
|
+
cyclePath: null,
|
|
174
|
+
};
|
|
175
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
176
|
+
result.ok = false;
|
|
177
|
+
result.error = 'projectRoot must be a non-empty string';
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
if (typeof perFeaturePath !== 'string' || perFeaturePath.length === 0) {
|
|
181
|
+
result.ok = false;
|
|
182
|
+
result.error = 'perFeaturePath must be a non-empty string';
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 1. Load the root (per-feature) file. We don't go through getFeaturePath here because the
|
|
187
|
+
// caller might pass an arbitrary absolute path; the schema parser handles a missing file
|
|
188
|
+
// via parseFeatureMemoryFile only if we read it ourselves.
|
|
189
|
+
const fs = require('node:fs');
|
|
190
|
+
let rootRaw;
|
|
191
|
+
try {
|
|
192
|
+
rootRaw = fs.readFileSync(perFeaturePath, 'utf8');
|
|
193
|
+
} catch (e) {
|
|
194
|
+
result.ok = false;
|
|
195
|
+
result.error = `failed to read root file ${perFeaturePath}: ${e && e.message ? e.message : String(e)}`;
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
let rootFile;
|
|
199
|
+
try {
|
|
200
|
+
rootFile = schema.parseFeatureMemoryFile(rootRaw);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
result.ok = false;
|
|
203
|
+
result.error = `failed to parse root file ${perFeaturePath}: ${e && e.message ? e.message : String(e)}`;
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
// Derive a chain-display ref for the root layer. Prefer `feature` from frontmatter, else
|
|
207
|
+
// the basename without extension. This is purely cosmetic — the cycle detector keys on
|
|
208
|
+
// the platform refs, which are unique on their own.
|
|
209
|
+
const rootRef = (rootFile.frontmatter && typeof rootFile.frontmatter.feature === 'string'
|
|
210
|
+
&& rootFile.frontmatter.feature.length > 0)
|
|
211
|
+
? rootFile.frontmatter.feature
|
|
212
|
+
: path.basename(perFeaturePath, '.md');
|
|
213
|
+
result.layers.push({
|
|
214
|
+
kind: 'feature',
|
|
215
|
+
ref: rootRef,
|
|
216
|
+
path: perFeaturePath,
|
|
217
|
+
exists: true,
|
|
218
|
+
file: rootFile,
|
|
219
|
+
});
|
|
220
|
+
result.chain.push(rootRef);
|
|
221
|
+
|
|
222
|
+
// 2. Single-pass walk of the extends chain.
|
|
223
|
+
// @cap-decision(F-078/AC-5) visited-set keys on the platform-ref string. The root feature
|
|
224
|
+
// ref is NOT added to the visited-set because a per-feature file referencing itself is
|
|
225
|
+
// structurally impossible (extends only points at platform/), and re-using the root ref
|
|
226
|
+
// would produce a confusing duplicate entry in the displayed cycle path.
|
|
227
|
+
const visited = new Set();
|
|
228
|
+
let current = rootFile;
|
|
229
|
+
let depth = 0;
|
|
230
|
+
// @cap-decision(F-078/iter1) Stage-2 #3 fix: drop double 'platform/' prefix in
|
|
231
|
+
// malformed-extends message. Track the *previous* layer's ref explicitly so the
|
|
232
|
+
// mid-chain malformed-extends error names the actual parent file (already a full
|
|
233
|
+
// ref like `platform/a`) instead of synthesizing `'platform/' + lastVisited`, which
|
|
234
|
+
// double-prefixed because visited entries are already full refs. The variable starts
|
|
235
|
+
// empty and is updated AFTER each successful push so it always points at the layer
|
|
236
|
+
// whose `extends:` field we're currently validating.
|
|
237
|
+
let lastRef = '';
|
|
238
|
+
while (current && current.frontmatter && current.frontmatter.extends) {
|
|
239
|
+
const extendsRef = String(current.frontmatter.extends).trim();
|
|
240
|
+
if (extendsRef === '') break;
|
|
241
|
+
|
|
242
|
+
// @cap-risk Validate the ref shape via the same regex the schema uses, so a malformed
|
|
243
|
+
// extends value (e.g. `extends: ../../etc/passwd`) is rejected here too. parseExtendsRef
|
|
244
|
+
// returns null on shape failure; we then surface a hard error rather than a soft warn,
|
|
245
|
+
// because a malformed extends is an authoring bug, not a missing-file condition.
|
|
246
|
+
const parsed = parseExtendsRef(extendsRef);
|
|
247
|
+
if (!parsed) {
|
|
248
|
+
result.ok = false;
|
|
249
|
+
// @cap-decision(F-078/iter1) Stage-2 #1 fix: ANSI defense extended to extends-resolver.
|
|
250
|
+
// Both extendsRef (user-controlled frontmatter) and lastRef (also a user-derived
|
|
251
|
+
// upstream ref) are sanitized before interpolation. perFeaturePath is operator-supplied,
|
|
252
|
+
// not user-controlled, but we sanitize it anyway as defense-in-depth — log-injection
|
|
253
|
+
// class issues compound when even one slot is unsanitized.
|
|
254
|
+
const inLocation = current === rootFile
|
|
255
|
+
? _safeForError(perFeaturePath)
|
|
256
|
+
: _safeForError(lastRef || '?');
|
|
257
|
+
result.error = `invalid extends ref "${_safeForError(extendsRef)}" in ${inLocation} (must match platform/<topic>)`;
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// @cap-decision(F-078/AC-5) Cycle check FIRST, then push. Reverse order would let the
|
|
262
|
+
// duplicate slip into result.layers.
|
|
263
|
+
if (visited.has(_refKey(extendsRef))) {
|
|
264
|
+
// Cycle: build a display chain that includes the duplicate ref at the end so the
|
|
265
|
+
// user sees the loop close visually.
|
|
266
|
+
// @cap-decision(F-078/iter1) Stage-2 #1 fix: defense-in-depth — sanitize each ref in
|
|
267
|
+
// the cycle path before joining. parseExtendsRef anchors the topic shape, so refs
|
|
268
|
+
// SHOULD already be ANSI-clean, but the chain[0] is the ROOT ref which is derived
|
|
269
|
+
// from `frontmatter.feature` or basename — both user-controlled paths.
|
|
270
|
+
const cyclePath = [...result.chain, extendsRef].map(_safeForError).join(' → ');
|
|
271
|
+
result.ok = false;
|
|
272
|
+
result.cyclePath = cyclePath;
|
|
273
|
+
result.error = `cycle detected in extends chain: ${cyclePath}`;
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (depth >= MAX_CHAIN_DEPTH) {
|
|
278
|
+
// Safety net — cycle detector should always catch this first, but if not, fail loud.
|
|
279
|
+
result.ok = false;
|
|
280
|
+
result.cyclePath = [...result.chain, extendsRef].map(_safeForError).join(' → ');
|
|
281
|
+
result.error = `extends chain exceeds max depth ${MAX_CHAIN_DEPTH}: ${result.cyclePath}`;
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
visited.add(_refKey(extendsRef));
|
|
286
|
+
|
|
287
|
+
// Load the next layer.
|
|
288
|
+
const layer = loadLayer(projectRoot, extendsRef);
|
|
289
|
+
result.layers.push(layer);
|
|
290
|
+
result.chain.push(extendsRef);
|
|
291
|
+
|
|
292
|
+
if (!layer.exists || !layer.file) {
|
|
293
|
+
// @cap-decision(F-078/spec-gap) Dangling extends is SOFT-warn, not fatal. Reasoning:
|
|
294
|
+
// the spec says "validate that referenced topic exists OR deferred-warning if not
|
|
295
|
+
// (don't hard-block on dangling extends)". A platform topic might be created in a
|
|
296
|
+
// sibling PR, and a hard-block here would force ordering between PRs. The resolved
|
|
297
|
+
// view excludes the dangling layer (we don't push the missing file's content into
|
|
298
|
+
// any merged-view), but the chain still records that we attempted the link.
|
|
299
|
+
// @cap-decision(F-078/iter1) Stage-2 #1 fix: ANSI-sanitize the ref + path in the
|
|
300
|
+
// dangling warning text. layer.path is derived from a sanitized topic (via the
|
|
301
|
+
// platform path helper), so already clean — but defense-in-depth is cheap.
|
|
302
|
+
result.warnings.push(`dangling extends: ${_safeForError(extendsRef)} (file not found at ${_safeForError(layer.path)})`);
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Continue walking from the layer we just loaded. Update lastRef AFTER push so a
|
|
307
|
+
// subsequent malformed-extends error names this layer (the parent of the bad ref).
|
|
308
|
+
lastRef = extendsRef;
|
|
309
|
+
current = layer.file;
|
|
310
|
+
depth += 1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// -------- Merged view --------
|
|
317
|
+
|
|
318
|
+
// @cap-decision(F-078/spec-gap) Merge semantics: when collapsing the layer chain into a
|
|
319
|
+
// single view, AUTO-block decisions/pitfalls CONCAT (preserve all sources, deduped on
|
|
320
|
+
// `text + location` to avoid noise on re-runs). FRONTMATTER fields use OVERRIDE-from-root
|
|
321
|
+
// (the per-feature file wins on conflict) — the per-feature file is the authoritative
|
|
322
|
+
// authoring point. The MANUAL-block raw text is NOT merged: it lives only on the root file
|
|
323
|
+
// (extending platform manual lessons would be confusing on re-runs and is out of scope for
|
|
324
|
+
// AC-3 which only asks for the chain to RESOLVE, not for a fully-merged authoring view).
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Collapse a resolved extends chain into a single merged view (concat auto-block, override
|
|
328
|
+
* frontmatter from root, manual-block from root only).
|
|
329
|
+
*
|
|
330
|
+
* @param {ResolveResult} resolved
|
|
331
|
+
* @returns {{frontmatter:Object, autoBlock:{decisions:Array<{text:string,location:string}>, pitfalls:Array<{text:string,location:string}>}, manualBlock:{raw:string}, layerCount:number}}
|
|
332
|
+
*/
|
|
333
|
+
function mergeResolvedView(resolved) {
|
|
334
|
+
if (!resolved || !resolved.ok) {
|
|
335
|
+
throw new Error('mergeResolvedView: cannot merge an unresolved chain');
|
|
336
|
+
}
|
|
337
|
+
const layers = resolved.layers || [];
|
|
338
|
+
if (layers.length === 0) {
|
|
339
|
+
return {
|
|
340
|
+
frontmatter: Object.create(null),
|
|
341
|
+
autoBlock: { decisions: [], pitfalls: [] },
|
|
342
|
+
manualBlock: { raw: '' },
|
|
343
|
+
layerCount: 0,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const root = layers[0];
|
|
347
|
+
// @cap-decision(F-078/iter1) Stage-2 #2 fix: deep-clone frontmatter on merge (F-082 lesson).
|
|
348
|
+
// Object.assign was a shallow copy — array values (`related_features`, `key_files`) shared
|
|
349
|
+
// their references with the parsed source file. A caller doing
|
|
350
|
+
// `merged.frontmatter.related_features.push(...)` would silently mutate the upstream parsed
|
|
351
|
+
// file. Frontmatter is YAML-derived plain data (strings, numbers, arrays of strings),
|
|
352
|
+
// never functions or class instances, so JSON-roundtrip is safe and avoids the
|
|
353
|
+
// structuredClone+`Object.create(null)` proto-edge-case (structuredClone preserves the
|
|
354
|
+
// null-prototype, which we want, but the JSON path is the simpler proven contract here).
|
|
355
|
+
// We then re-prototype the result with `Object.create(null)` to keep the same proto-poison
|
|
356
|
+
// defense the original code provided.
|
|
357
|
+
const frontmatter = _deepCloneFrontmatter(root.file ? root.file.frontmatter : {});
|
|
358
|
+
const seen = new Set();
|
|
359
|
+
const decisions = [];
|
|
360
|
+
const pitfalls = [];
|
|
361
|
+
|
|
362
|
+
// Walk DEEPEST to ROOT so the root layer's entries appear last (most-recent-wins display
|
|
363
|
+
// order). On dedup, the LATER write wins because we check `seen` before pushing — but
|
|
364
|
+
// since dedup is keyed on text+location, "winner" is irrelevant anyway.
|
|
365
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
366
|
+
const layer = layers[i];
|
|
367
|
+
if (!layer.file || !layer.file.autoBlock) continue;
|
|
368
|
+
for (const d of layer.file.autoBlock.decisions || []) {
|
|
369
|
+
const k = `D|${d.text}|${d.location}`;
|
|
370
|
+
if (seen.has(k)) continue;
|
|
371
|
+
seen.add(k);
|
|
372
|
+
decisions.push({ text: d.text, location: d.location, sourceRef: layer.ref });
|
|
373
|
+
}
|
|
374
|
+
for (const p of layer.file.autoBlock.pitfalls || []) {
|
|
375
|
+
const k = `P|${p.text}|${p.location}`;
|
|
376
|
+
if (seen.has(k)) continue;
|
|
377
|
+
seen.add(k);
|
|
378
|
+
pitfalls.push({ text: p.text, location: p.location, sourceRef: layer.ref });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
frontmatter,
|
|
384
|
+
autoBlock: { decisions, pitfalls },
|
|
385
|
+
manualBlock: { raw: root.file ? (root.file.manualBlock ? root.file.manualBlock.raw : '') : '' },
|
|
386
|
+
layerCount: layers.length,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// -------- Exports --------
|
|
391
|
+
|
|
392
|
+
module.exports = {
|
|
393
|
+
resolveExtends,
|
|
394
|
+
mergeResolvedView,
|
|
395
|
+
parseExtendsRef,
|
|
396
|
+
loadLayer,
|
|
397
|
+
MAX_CHAIN_DEPTH,
|
|
398
|
+
};
|