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,963 @@
|
|
|
1
|
+
// @cap-feature(feature:F-079, primary:true) Snapshot-Linkage to Features and Platform —
|
|
2
|
+
// wires .cap/snapshots/* into the F-076 memory layer.
|
|
3
|
+
//
|
|
4
|
+
// @cap-context This module owns the contract between snapshot creation (cap:save) and the
|
|
5
|
+
// per-feature / platform memory files. AC-1..AC-3 cover the WRITE-time linkage (frontmatter
|
|
6
|
+
// + soft-warn), AC-4 covers the pipeline-time idempotent re-linking, AC-5 covers the F-077
|
|
7
|
+
// migration heuristic for legacy orphans, and AC-6 covers the unassigned fallback bucket.
|
|
8
|
+
//
|
|
9
|
+
// @cap-context Auto-block contract: snapshot references live in their OWN auto-managed
|
|
10
|
+
// marker pair (`<!-- @auto-block linked_snapshots -->` ... `<!-- /@auto-block -->`),
|
|
11
|
+
// distinct from F-076's `<!-- cap:auto:start -->` block. F-076's parser/serializer is
|
|
12
|
+
// authoritative for decisions+pitfalls; touching it would change a shipped contract for
|
|
13
|
+
// every consumer. Snapshots get their own block so the two markers stay decoupled and
|
|
14
|
+
// either can evolve without breaking the other. Spec wording "Auto-Block des Per-Feature-
|
|
15
|
+
// Files unter Sektion linked_snapshots" is honored: `linked_snapshots` IS its own
|
|
16
|
+
// auto-managed block, just a sibling of F-076's auto-block rather than nested inside it.
|
|
17
|
+
//
|
|
18
|
+
// @cap-decision(F-079/AC-4) Auto-block isolation — `linked_snapshots` uses dedicated
|
|
19
|
+
// marker pair `<!-- @auto-block linked_snapshots -->` ... `<!-- /@auto-block -->` separate
|
|
20
|
+
// from F-076's `cap:auto:start/end`. Trade-off: two marker pairs in the same file vs.
|
|
21
|
+
// modifying the shipped F-076 schema parser. Two pairs keep blast radius zero — F-076
|
|
22
|
+
// tests stay green and any future block type (e.g. F-080 claude-native bridge) can reuse
|
|
23
|
+
// the same `@auto-block <name>` pattern without needing a parser change.
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const session = require('./cap-session.cjs');
|
|
31
|
+
const schema = require('./cap-memory-schema.cjs');
|
|
32
|
+
const platformLib = require('./cap-memory-platform.cjs');
|
|
33
|
+
const { _atomicWriteFile } = require('./cap-memory-migrate.cjs');
|
|
34
|
+
|
|
35
|
+
// -------- Constants --------
|
|
36
|
+
|
|
37
|
+
// @cap-decision(F-079/D1) Snapshot directory is fixed at .cap/snapshots/. Mirrors F-077's
|
|
38
|
+
// SNAPSHOTS_DIR (defined inline there) — single contract across modules.
|
|
39
|
+
const SNAPSHOTS_DIR = path.join('.cap', 'snapshots');
|
|
40
|
+
|
|
41
|
+
// @cap-decision(F-079/D2) Linked-snapshots section uses its own marker pair inside the
|
|
42
|
+
// per-feature OR platform file. Format `<!-- @auto-block linked_snapshots -->` ...
|
|
43
|
+
// `<!-- /@auto-block -->`. The `@auto-block <name>` shape is intentionally generic so
|
|
44
|
+
// F-080 / future features can mount more named auto-managed blocks without inventing
|
|
45
|
+
// new marker conventions.
|
|
46
|
+
const LINKED_SNAPSHOTS_BLOCK_NAME = 'linked_snapshots';
|
|
47
|
+
const LINKED_SNAPSHOTS_START = `<!-- @auto-block ${LINKED_SNAPSHOTS_BLOCK_NAME} -->`;
|
|
48
|
+
const LINKED_SNAPSHOTS_END = '<!-- /@auto-block -->';
|
|
49
|
+
|
|
50
|
+
// @cap-decision(F-079/D3) snapshot-name slug regex: lowercase kebab-case alphanumerics,
|
|
51
|
+
// optionally allows internal `.` segments only via `_` (i.e. NO dots). Mirrors F-076's
|
|
52
|
+
// TOPIC_RE shape but tightened to forbid path-traversal byte forms. Snapshots traditionally
|
|
53
|
+
// embed dates (`2026-05-06-foo`) which the kebab regex already accepts.
|
|
54
|
+
const SNAPSHOT_NAME_RE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
|
|
55
|
+
|
|
56
|
+
// @cap-decision(F-079/D4) Date-window for the migration heuristic (AC-5). 24h matches
|
|
57
|
+
// F-077's classifySnapshot SNAPSHOT_DATE_WINDOW_HOURS — same heuristic, same window. If a
|
|
58
|
+
// future tightening is needed, change it once here.
|
|
59
|
+
const SNAPSHOT_DATE_WINDOW_HOURS = 24;
|
|
60
|
+
|
|
61
|
+
// @cap-decision(F-079/D5) Unassigned snapshots topic name matches F-077's
|
|
62
|
+
// UNASSIGNED_SNAPSHOTS_TOPIC for cross-module consistency. Spec AC-6 names this file
|
|
63
|
+
// explicitly as `.cap/memory/platform/snapshots-unassigned.md`.
|
|
64
|
+
const UNASSIGNED_SNAPSHOTS_TOPIC = 'snapshots-unassigned';
|
|
65
|
+
|
|
66
|
+
// -------- Defensive helpers --------
|
|
67
|
+
|
|
68
|
+
// @cap-decision(F-079/iter1) Stage-2 #2: ANSI/control-byte sanitization for any user-supplied
|
|
69
|
+
// string that flows into stderr/throw messages. Mirrors cap-memory-platform.cjs:_safeForError
|
|
70
|
+
// — kept local so a refactor in one module can't silently weaken the defense in another.
|
|
71
|
+
function _safeForError(value) {
|
|
72
|
+
if (typeof value !== 'string') return String(value);
|
|
73
|
+
return value.replace(/[^\x20-\x7E]/g, '?').slice(0, 64);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// @cap-risk(reason:path-traversal-via-snapshot-name) Snapshot file paths are concatenated
|
|
77
|
+
// from a user-supplied snapshot name. Reject path separators, NUL bytes, and traversal
|
|
78
|
+
// sequences explicitly even though the slug regex would already catch them. Defense-in-depth
|
|
79
|
+
// matching F-078's _validateSlug pattern.
|
|
80
|
+
function _validateSnapshotName(name) {
|
|
81
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
82
|
+
throw new TypeError(`snapshot name must be a non-empty string (got ${typeof name})`);
|
|
83
|
+
}
|
|
84
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..') || name.includes('\0')) {
|
|
85
|
+
throw new TypeError(`snapshot name must not contain path separators or traversal sequences (got "${_safeForError(name)}")`);
|
|
86
|
+
}
|
|
87
|
+
if (!SNAPSHOT_NAME_RE.test(name)) {
|
|
88
|
+
throw new TypeError(`snapshot name must be kebab-case (got "${_safeForError(name)}")`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// @cap-risk(reason:proto-pollution-via-frontmatter) Topic / featureId values land inside
|
|
93
|
+
// frontmatter strings. parseSimpleYaml already strips __proto__/constructor/prototype keys,
|
|
94
|
+
// but defense-in-depth: reject the strings themselves if they match those reserved tokens
|
|
95
|
+
// when used as a topic/feature.
|
|
96
|
+
function _validateTopic(topic) {
|
|
97
|
+
if (typeof topic !== 'string' || topic.length === 0) {
|
|
98
|
+
throw new TypeError(`topic must be a non-empty string (got ${typeof topic})`);
|
|
99
|
+
}
|
|
100
|
+
if (topic === '__proto__' || topic === 'constructor' || topic === 'prototype') {
|
|
101
|
+
throw new TypeError(`topic name reserved (got "${_safeForError(topic)}")`);
|
|
102
|
+
}
|
|
103
|
+
if (topic.includes('/') || topic.includes('\\') || topic.includes('..') || topic.includes('\0')) {
|
|
104
|
+
throw new TypeError(`topic must not contain path separators or traversal sequences (got "${_safeForError(topic)}")`);
|
|
105
|
+
}
|
|
106
|
+
if (!platformLib.PLATFORM_TOPIC_RE.test(topic)) {
|
|
107
|
+
throw new TypeError(`topic must be kebab-case slug (got "${_safeForError(topic)}")`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// @cap-decision(F-079/iter1) Stage-2 #3 fix: F-076-style "marker line must contain ONLY
|
|
112
|
+
// the marker after trim". Returns ALL byte offsets of qualifying marker lines so the
|
|
113
|
+
// caller can both pair start/end AND detect duplicate-block accidents.
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} content
|
|
116
|
+
* @param {string} marker
|
|
117
|
+
* @returns {{offset:number, lineNo:number}[]}
|
|
118
|
+
*/
|
|
119
|
+
function _findMarkerLinePositions(content, marker) {
|
|
120
|
+
/** @type {{offset:number, lineNo:number}[]} */
|
|
121
|
+
const out = [];
|
|
122
|
+
// Iterate manually so we can track byte offsets without regex zero-length match traps.
|
|
123
|
+
let cursor = 0;
|
|
124
|
+
let lineNo = 0;
|
|
125
|
+
while (cursor <= content.length) {
|
|
126
|
+
let nl = content.indexOf('\n', cursor);
|
|
127
|
+
if (nl === -1) nl = content.length;
|
|
128
|
+
const lineStart = cursor;
|
|
129
|
+
let line = content.slice(lineStart, nl);
|
|
130
|
+
// Tolerate CRLF — strip a trailing \r from the line content.
|
|
131
|
+
if (line.length > 0 && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
|
|
132
|
+
lineNo++;
|
|
133
|
+
const trimmed = line.replace(/^\s+|\s+$/g, '');
|
|
134
|
+
if (trimmed === marker) {
|
|
135
|
+
const markerCol = line.indexOf(marker);
|
|
136
|
+
out.push({ offset: lineStart + (markerCol >= 0 ? markerCol : 0), lineNo });
|
|
137
|
+
}
|
|
138
|
+
if (nl === content.length) break;
|
|
139
|
+
cursor = nl + 1;
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// -------- Snapshot frontmatter helpers --------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @typedef {Object} SnapshotFrontmatter
|
|
148
|
+
* @property {string=} session
|
|
149
|
+
* @property {string=} date
|
|
150
|
+
* @property {string=} branch
|
|
151
|
+
* @property {string=} source
|
|
152
|
+
* @property {string=} feature - F-NNN id (mutually exclusive with `platform`)
|
|
153
|
+
* @property {string=} platform - kebab-case topic (mutually exclusive with `feature`)
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @typedef {Object} SnapshotRecord
|
|
158
|
+
* @property {string} name - basename without .md
|
|
159
|
+
* @property {string} relPath - .cap/snapshots/<name>.md (forward-slash form)
|
|
160
|
+
* @property {string} absPath
|
|
161
|
+
* @property {SnapshotFrontmatter} frontmatter
|
|
162
|
+
* @property {string} title - first H1 if any, else name
|
|
163
|
+
* @property {string} raw - full file content
|
|
164
|
+
*/
|
|
165
|
+
|
|
166
|
+
// @cap-todo(ac:F-079/AC-1) parseSnapshotFile reads a single .cap/snapshots/<name>.md and
|
|
167
|
+
// returns its frontmatter (incl. feature/platform routing) + title.
|
|
168
|
+
/**
|
|
169
|
+
* @param {string} projectRoot
|
|
170
|
+
* @param {string} snapshotName
|
|
171
|
+
* @returns {SnapshotRecord|null}
|
|
172
|
+
*/
|
|
173
|
+
function parseSnapshotFile(projectRoot, snapshotName) {
|
|
174
|
+
_validateSnapshotName(snapshotName);
|
|
175
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
176
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
177
|
+
}
|
|
178
|
+
const absPath = path.join(projectRoot, SNAPSHOTS_DIR, `${snapshotName}.md`);
|
|
179
|
+
if (!fs.existsSync(absPath)) return null;
|
|
180
|
+
const raw = fs.readFileSync(absPath, 'utf8');
|
|
181
|
+
return _parseSnapshotContent(snapshotName, absPath, raw);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} snapshotName
|
|
186
|
+
* @param {string} absPath
|
|
187
|
+
* @param {string} raw
|
|
188
|
+
* @returns {SnapshotRecord}
|
|
189
|
+
*/
|
|
190
|
+
function _parseSnapshotContent(snapshotName, absPath, raw) {
|
|
191
|
+
/** @type {SnapshotFrontmatter} */
|
|
192
|
+
const fm = Object.create(null);
|
|
193
|
+
// @cap-decision(F-079/D6) Reuse a minimal YAML extractor here rather than depend on
|
|
194
|
+
// cap-memory-schema's parseFeatureMemoryFile — snapshot frontmatter is plain key:value
|
|
195
|
+
// (no inline arrays today) and doesn't carry F-076's auto-block markers. Keeping this
|
|
196
|
+
// local avoids a bidirectional dep on F-076 just to read 5 scalars.
|
|
197
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
198
|
+
if (fmMatch) {
|
|
199
|
+
const body = fmMatch[1];
|
|
200
|
+
const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
|
|
201
|
+
for (const line of body.split(/\r?\n/)) {
|
|
202
|
+
const m = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
|
|
203
|
+
if (!m) continue;
|
|
204
|
+
const key = m[1];
|
|
205
|
+
if (RESERVED.has(key)) continue;
|
|
206
|
+
const val = (m[2] || '').replace(/^["']|["']$/g, '').trim();
|
|
207
|
+
if (key === 'session') fm.session = val;
|
|
208
|
+
else if (key === 'date') fm.date = val;
|
|
209
|
+
else if (key === 'branch') fm.branch = val;
|
|
210
|
+
else if (key === 'source') fm.source = val;
|
|
211
|
+
else if (key === 'feature') fm.feature = val;
|
|
212
|
+
else if (key === 'platform') fm.platform = val;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Title: first H1.
|
|
216
|
+
let title = snapshotName;
|
|
217
|
+
const h1 = raw.match(/^#\s+(.+?)\s*$/m);
|
|
218
|
+
if (h1) title = h1[1].trim();
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
name: snapshotName,
|
|
222
|
+
relPath: `${SNAPSHOTS_DIR.replace(/\\/g, '/')}/${snapshotName}.md`,
|
|
223
|
+
absPath,
|
|
224
|
+
frontmatter: fm,
|
|
225
|
+
title,
|
|
226
|
+
raw,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// @cap-todo(ac:F-079/AC-1) listSnapshots enumerates snapshot basenames (without .md).
|
|
231
|
+
/**
|
|
232
|
+
* @param {string} projectRoot
|
|
233
|
+
* @returns {string[]} sorted list of snapshot basenames (no extension)
|
|
234
|
+
*/
|
|
235
|
+
function listSnapshots(projectRoot) {
|
|
236
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
237
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
238
|
+
}
|
|
239
|
+
const dir = path.join(projectRoot, SNAPSHOTS_DIR);
|
|
240
|
+
if (!fs.existsSync(dir)) return [];
|
|
241
|
+
let entries;
|
|
242
|
+
try {
|
|
243
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
244
|
+
} catch (_e) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
/** @type {string[]} */
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const e of entries) {
|
|
250
|
+
if (!e || typeof e.name !== 'string') continue;
|
|
251
|
+
if (e.isDirectory && e.isDirectory()) continue;
|
|
252
|
+
if (!e.name.endsWith('.md')) continue;
|
|
253
|
+
const slug = e.name.slice(0, -3);
|
|
254
|
+
// Defensive: skip files whose name fails the slug regex — they could be hand-edited
|
|
255
|
+
// experiments and we don't want them to crash listSnapshots, just to be ignored.
|
|
256
|
+
if (!SNAPSHOT_NAME_RE.test(slug)) continue;
|
|
257
|
+
out.push(slug);
|
|
258
|
+
}
|
|
259
|
+
out.sort();
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// -------- AC-1/AC-2/AC-3: Save-time options resolution --------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @typedef {Object} SaveOptions
|
|
267
|
+
* @property {boolean=} unassigned - --unassigned flag
|
|
268
|
+
* @property {string=} platform - --platform=<topic> flag value (if present)
|
|
269
|
+
* @property {string=} _explicitFeatureOverride - test-only seam (not a CLI flag); lets unit
|
|
270
|
+
* tests drive the explicit-feature branch without writing SESSION.json. Public CLI surface
|
|
271
|
+
* per AC-2 stays at exactly two flags: --unassigned and --platform=<topic>.
|
|
272
|
+
* @property {string=} activeFeature - injected active feature ID (test seam — defaults to SESSION.json)
|
|
273
|
+
*/
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @typedef {Object} ResolvedLinkage
|
|
277
|
+
* @property {'feature'|'platform'|'unassigned'} kind
|
|
278
|
+
* @property {string|null} featureId
|
|
279
|
+
* @property {string|null} topic
|
|
280
|
+
* @property {string|null} warning - non-null = soft-warn message (AC-3)
|
|
281
|
+
* @property {Partial<SnapshotFrontmatter>} frontmatterPatch
|
|
282
|
+
*/
|
|
283
|
+
|
|
284
|
+
// @cap-feature(feature:F-079) resolveLinkageOptions — AC-1+AC-2+AC-3 single dispatcher.
|
|
285
|
+
//
|
|
286
|
+
// @cap-todo(ac:F-079/AC-1) When neither --unassigned nor --platform= is given, default to
|
|
287
|
+
// reading activeFeature from SESSION.json. If present, link the snapshot to that feature.
|
|
288
|
+
// @cap-todo(ac:F-079/AC-2) --unassigned and --platform=<topic> are mutually exclusive. Both
|
|
289
|
+
// together → loud parse-error (caller surfaces via process.exitCode + stderr).
|
|
290
|
+
// @cap-todo(ac:F-079/AC-3) Soft-warn (no fail) emits when the explicit --unassigned flag is
|
|
291
|
+
// set OR when no activeFeature is in SESSION.json. The snapshot is always created.
|
|
292
|
+
// @cap-decision(F-079/AC-2) Mutually-exclusive flags throw early via TypeError so the caller
|
|
293
|
+
// surfaces the error before any filesystem write happens. cap:save then exits non-zero with
|
|
294
|
+
// stderr — the snapshot is NOT created on parse-error. This is a HARD-fail (parse error),
|
|
295
|
+
// distinct from AC-3's SOFT-warn (linkage missing).
|
|
296
|
+
// @cap-decision(F-079/AC-3) Soft-warn rationale: snapshot creation is best-effort linkage.
|
|
297
|
+
// The user's primary intent is to capture context; failing the save because we can't link
|
|
298
|
+
// would lose data. Linkage failures emit on stderr and are non-fatal.
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Resolve the linkage options for a cap:save invocation.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} projectRoot
|
|
304
|
+
* @param {SaveOptions=} options
|
|
305
|
+
* @returns {ResolvedLinkage}
|
|
306
|
+
*/
|
|
307
|
+
function resolveLinkageOptions(projectRoot, options) {
|
|
308
|
+
const opts = options || {};
|
|
309
|
+
const unassigned = opts.unassigned === true;
|
|
310
|
+
const platformRaw = (typeof opts.platform === 'string' && opts.platform.length > 0)
|
|
311
|
+
? opts.platform
|
|
312
|
+
: null;
|
|
313
|
+
// @cap-decision(F-079/iter1) Stage-2 #4 fix: test-only seam renamed from `feature` to
|
|
314
|
+
// `_explicitFeatureOverride` so the public API surface signals "this is NOT a CLI flag".
|
|
315
|
+
// commands/cap/save.md exposes only --unassigned and --platform= per AC-2; the seam
|
|
316
|
+
// exists purely to keep unit tests deterministic without writing SESSION.json on disk.
|
|
317
|
+
const explicitFeature = (typeof opts._explicitFeatureOverride === 'string' && opts._explicitFeatureOverride.length > 0)
|
|
318
|
+
? opts._explicitFeatureOverride
|
|
319
|
+
: null;
|
|
320
|
+
|
|
321
|
+
// AC-2: mutually-exclusive flag combinations.
|
|
322
|
+
if (unassigned && platformRaw) {
|
|
323
|
+
// Loud parse error — caller decides exit semantics.
|
|
324
|
+
throw new TypeError('cap:save: --unassigned and --platform=<topic> are mutually exclusive — pick one');
|
|
325
|
+
}
|
|
326
|
+
if (unassigned && explicitFeature) {
|
|
327
|
+
throw new TypeError('cap:save: --unassigned and explicit feature override are mutually exclusive — pick one');
|
|
328
|
+
}
|
|
329
|
+
if (platformRaw && explicitFeature) {
|
|
330
|
+
throw new TypeError('cap:save: --platform=<topic> and explicit feature override are mutually exclusive — pick one');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// AC-2 platform branch: validate topic shape and route.
|
|
334
|
+
if (platformRaw !== null) {
|
|
335
|
+
_validateTopic(platformRaw);
|
|
336
|
+
return {
|
|
337
|
+
kind: 'platform',
|
|
338
|
+
featureId: null,
|
|
339
|
+
topic: platformRaw,
|
|
340
|
+
warning: null,
|
|
341
|
+
frontmatterPatch: { platform: platformRaw },
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// AC-2 / AC-3 unassigned branch: explicit user intent → soft-warn + no link.
|
|
346
|
+
if (unassigned) {
|
|
347
|
+
return {
|
|
348
|
+
kind: 'unassigned',
|
|
349
|
+
featureId: null,
|
|
350
|
+
topic: null,
|
|
351
|
+
warning: 'cap:save: --unassigned set; snapshot will not be linked to any feature or platform topic',
|
|
352
|
+
frontmatterPatch: {},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// AC-2 explicit-feature branch (test seam — opts._explicitFeatureOverride; NOT a CLI flag).
|
|
357
|
+
if (explicitFeature !== null) {
|
|
358
|
+
if (!schema.FEATURE_ID_RE.test(explicitFeature)) {
|
|
359
|
+
throw new TypeError(`cap:save: explicit feature override must match feature id regex (got "${_safeForError(explicitFeature)}")`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
kind: 'feature',
|
|
363
|
+
featureId: explicitFeature,
|
|
364
|
+
topic: null,
|
|
365
|
+
warning: null,
|
|
366
|
+
frontmatterPatch: { feature: explicitFeature },
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// AC-1 default: read activeFeature from SESSION.json.
|
|
371
|
+
// @cap-decision(F-079/iter1) Test seam: opts.activeFeature wins over SESSION.json so unit
|
|
372
|
+
// tests can drive every branch without writing a SESSION.json file each time. In production
|
|
373
|
+
// this field is never set by the cap:save command — only by tests.
|
|
374
|
+
let activeFeature = (typeof opts.activeFeature === 'string' && opts.activeFeature.length > 0)
|
|
375
|
+
? opts.activeFeature
|
|
376
|
+
: null;
|
|
377
|
+
if (activeFeature === null) {
|
|
378
|
+
try {
|
|
379
|
+
const sess = session.loadSession(projectRoot);
|
|
380
|
+
if (sess && typeof sess.activeFeature === 'string' && sess.activeFeature.length > 0) {
|
|
381
|
+
activeFeature = sess.activeFeature;
|
|
382
|
+
}
|
|
383
|
+
} catch (_e) {
|
|
384
|
+
// loadSession is supposed to be defensive; ignore any unexpected error.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (activeFeature !== null) {
|
|
388
|
+
if (!schema.FEATURE_ID_RE.test(activeFeature)) {
|
|
389
|
+
// SESSION.json had a malformed activeFeature — soft-warn rather than throw because the
|
|
390
|
+
// user didn't supply this directly; treat as "no link available".
|
|
391
|
+
return {
|
|
392
|
+
kind: 'unassigned',
|
|
393
|
+
featureId: null,
|
|
394
|
+
topic: null,
|
|
395
|
+
warning: `cap:save: activeFeature in SESSION.json ("${_safeForError(activeFeature)}") does not match feature-id regex; saving without linkage`,
|
|
396
|
+
frontmatterPatch: {},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
kind: 'feature',
|
|
401
|
+
featureId: activeFeature,
|
|
402
|
+
topic: null,
|
|
403
|
+
warning: null,
|
|
404
|
+
frontmatterPatch: { feature: activeFeature },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// AC-3: no activeFeature → soft-warn + unassigned.
|
|
409
|
+
// @cap-decision(F-079/followup) F-079-FIX-A: warning rephrase to only mention real CLI flags.
|
|
410
|
+
// Previously the warning advertised `--feature/--platform/--unassigned`, but `--feature`
|
|
411
|
+
// is NOT a real CLI flag — it was renamed to the `_explicitFeatureOverride` test-seam in
|
|
412
|
+
// F-079/iter1 to keep the public CLI surface aligned with AC-2 (exactly two flags). The
|
|
413
|
+
// warning text now only mentions the actual user-facing flags.
|
|
414
|
+
return {
|
|
415
|
+
kind: 'unassigned',
|
|
416
|
+
featureId: null,
|
|
417
|
+
topic: null,
|
|
418
|
+
warning: 'cap:save: no activeFeature set in SESSION.json and no --platform/--unassigned flag; snapshot will be saved without linkage',
|
|
419
|
+
frontmatterPatch: {},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// @cap-feature(feature:F-079) injectLinkageFrontmatter — pure helper that takes a raw snapshot
|
|
424
|
+
// markdown body (with or without existing frontmatter) and returns a new body with the
|
|
425
|
+
// linkage fields merged into frontmatter. Used by cap:save (test seam: keeps the file-IO
|
|
426
|
+
// path separate from string transformation).
|
|
427
|
+
//
|
|
428
|
+
// @cap-decision(F-079/D7) Always emit `feature:` OR `platform:` (or neither for unassigned)
|
|
429
|
+
// in the frontmatter of the snapshot file. Never emit both — the resolver guarantees that.
|
|
430
|
+
// When a snapshot is saved as `--unassigned`, no linkage line is added at all (rather than
|
|
431
|
+
// emitting `feature: null`), so the F-077 migration heuristic later sees a true orphan.
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* @param {string} body - existing snapshot markdown content
|
|
435
|
+
* @param {Partial<SnapshotFrontmatter>} patch
|
|
436
|
+
* @returns {string} new body with patch applied
|
|
437
|
+
*/
|
|
438
|
+
function injectLinkageFrontmatter(body, patch) {
|
|
439
|
+
if (typeof body !== 'string') {
|
|
440
|
+
throw new TypeError('body must be a string');
|
|
441
|
+
}
|
|
442
|
+
if (!patch || typeof patch !== 'object') {
|
|
443
|
+
return body;
|
|
444
|
+
}
|
|
445
|
+
// Strip any existing feature: / platform: lines from frontmatter (re-write).
|
|
446
|
+
const fmMatch = body.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
447
|
+
if (!fmMatch) {
|
|
448
|
+
// No frontmatter at all — synthesize a minimal block.
|
|
449
|
+
const lines = ['---'];
|
|
450
|
+
if (patch.feature) lines.push(`feature: ${patch.feature}`);
|
|
451
|
+
if (patch.platform) lines.push(`platform: ${patch.platform}`);
|
|
452
|
+
if (lines.length === 1) {
|
|
453
|
+
// No additions — return body verbatim.
|
|
454
|
+
return body;
|
|
455
|
+
}
|
|
456
|
+
lines.push('---');
|
|
457
|
+
return `${lines.join('\n')}\n\n${body}`;
|
|
458
|
+
}
|
|
459
|
+
const fmBody = fmMatch[1];
|
|
460
|
+
const filtered = fmBody.split(/\r?\n/).filter((line) => {
|
|
461
|
+
return !/^(feature|platform)\s*:/.test(line.trim());
|
|
462
|
+
});
|
|
463
|
+
if (patch.feature) filtered.push(`feature: ${patch.feature}`);
|
|
464
|
+
if (patch.platform) filtered.push(`platform: ${patch.platform}`);
|
|
465
|
+
// Reconstruct the closing fence with the exact same trailing newline shape the original
|
|
466
|
+
// had (match either `\n---\n` or `\n---\n` at end of fm). fmMatch[0] already includes any
|
|
467
|
+
// trailing newline after the closing `---`, so we just splice from there.
|
|
468
|
+
const newFm = `---\n${filtered.join('\n')}\n---\n`;
|
|
469
|
+
const rest = body.slice(fmMatch[0].length);
|
|
470
|
+
return newFm + rest;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// -------- AC-4: Linked-snapshot block parsing/rendering --------
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* @typedef {Object} LinkedSnapshotEntry
|
|
477
|
+
* @property {string} name
|
|
478
|
+
* @property {string|null} date - ISO date (or short YYYY-MM-DD) extracted from frontmatter
|
|
479
|
+
* @property {string|null} branch - branch from snapshot frontmatter (display only)
|
|
480
|
+
*/
|
|
481
|
+
|
|
482
|
+
// @cap-todo(ac:F-079/AC-4) parseLinkedSnapshotsBlock locates the dedicated marker pair in a
|
|
483
|
+
// target file and returns the parsed entries (idempotent re-write contract).
|
|
484
|
+
//
|
|
485
|
+
// @cap-decision(F-079/iter1) Stage-2 #3 fix: parser hardened against in-prose mentions and
|
|
486
|
+
// duplicate blocks — mirrors F-076's `_countMarkerLines` semantics (marker must be the
|
|
487
|
+
// ENTIRE trimmed line) so a Lessons section that documents the marker text doesn't get
|
|
488
|
+
// picked up as a marker. Two `<!-- @auto-block linked_snapshots -->` markers in the same
|
|
489
|
+
// file → loud throw with both line-positions (F-082 lesson: silent drop is the worst
|
|
490
|
+
// failure mode; loud-failure is the contract).
|
|
491
|
+
/**
|
|
492
|
+
* @param {string} content
|
|
493
|
+
* @returns {{startIdx:number, endIdx:number, entries:LinkedSnapshotEntry[]}|null}
|
|
494
|
+
*/
|
|
495
|
+
function parseLinkedSnapshotsBlock(content) {
|
|
496
|
+
if (typeof content !== 'string') return null;
|
|
497
|
+
// Find marker lines where the marker IS the entire trimmed line content (mirrors
|
|
498
|
+
// cap-memory-schema.cjs:_countMarkerLines). This ignores in-prose mentions (e.g. inside
|
|
499
|
+
// a code-fence in the manual region) that would otherwise collide with bare indexOf.
|
|
500
|
+
const startLines = _findMarkerLinePositions(content, LINKED_SNAPSHOTS_START);
|
|
501
|
+
const endLines = _findMarkerLinePositions(content, LINKED_SNAPSHOTS_END);
|
|
502
|
+
if (startLines.length === 0 || endLines.length === 0) return null;
|
|
503
|
+
if (startLines.length > 1) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
`parseLinkedSnapshotsBlock: expected exactly one ${LINKED_SNAPSHOTS_START}, found ${startLines.length} ` +
|
|
506
|
+
`(at byte offsets ${startLines.map((p) => p.offset).join(', ')})`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const startIdx = startLines[0].offset;
|
|
510
|
+
// Pair with the first end-marker AFTER the start-marker.
|
|
511
|
+
const pairedEnd = endLines.find((p) => p.offset > startIdx + LINKED_SNAPSHOTS_START.length);
|
|
512
|
+
if (!pairedEnd) return null;
|
|
513
|
+
const endIdx = pairedEnd.offset;
|
|
514
|
+
const body = content.slice(startIdx + LINKED_SNAPSHOTS_START.length, endIdx);
|
|
515
|
+
/** @type {LinkedSnapshotEntry[]} */
|
|
516
|
+
const entries = [];
|
|
517
|
+
const lineRe = /^-\s+([a-z0-9][a-z0-9_-]*)\s*(?:\(([^)]+)\))?\s*$/i;
|
|
518
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
519
|
+
const line = raw.replace(/^\s+|\s+$/g, '');
|
|
520
|
+
if (!line.startsWith('- ')) continue;
|
|
521
|
+
const m = line.match(lineRe);
|
|
522
|
+
if (!m) continue;
|
|
523
|
+
const name = m[1];
|
|
524
|
+
let date = null;
|
|
525
|
+
let branch = null;
|
|
526
|
+
if (m[2]) {
|
|
527
|
+
// metadata is "<date>, branch: <branch>" or "<date>" or "branch: <branch>"
|
|
528
|
+
const parts = m[2].split(',').map((s) => s.trim());
|
|
529
|
+
for (const p of parts) {
|
|
530
|
+
const bm = p.match(/^branch:\s*(.+)$/i);
|
|
531
|
+
if (bm) branch = bm[1].trim();
|
|
532
|
+
else if (/^\d{4}-\d{2}-\d{2}/.test(p)) date = p;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
entries.push({ name, date, branch });
|
|
536
|
+
}
|
|
537
|
+
return { startIdx, endIdx: endIdx + LINKED_SNAPSHOTS_END.length, entries };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// @cap-todo(ac:F-079/AC-4) renderLinkedSnapshotsBlock emits a stable, sorted, deduped block.
|
|
541
|
+
// Same input → byte-identical output (idempotent contract).
|
|
542
|
+
//
|
|
543
|
+
// @cap-decision(F-079/AC-4) Sort by (date asc, name asc) so a re-run with the same set of
|
|
544
|
+
// snapshots produces identical output. Dedup by snapshot name. Empty list → still emit
|
|
545
|
+
// the marker pair on their own lines (with one blank between) so a future snapshot has
|
|
546
|
+
// a stable insertion point and the round-trip is byte-stable on no-snapshots. (Stage-2
|
|
547
|
+
// #3: empty-block-injection guard.)
|
|
548
|
+
/**
|
|
549
|
+
* @param {LinkedSnapshotEntry[]} entries
|
|
550
|
+
* @returns {string}
|
|
551
|
+
*/
|
|
552
|
+
function renderLinkedSnapshotsBlock(entries) {
|
|
553
|
+
const list = Array.isArray(entries) ? entries.slice() : [];
|
|
554
|
+
// Dedup by name — last write wins on metadata.
|
|
555
|
+
const byName = new Map();
|
|
556
|
+
for (const e of list) {
|
|
557
|
+
if (!e || typeof e.name !== 'string') continue;
|
|
558
|
+
byName.set(e.name, {
|
|
559
|
+
name: e.name,
|
|
560
|
+
date: (e.date && /^\d{4}-\d{2}-\d{2}/.test(e.date)) ? e.date.slice(0, 10) : null,
|
|
561
|
+
branch: typeof e.branch === 'string' && e.branch.length > 0 ? e.branch : null,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const sorted = [...byName.values()].sort((a, b) => {
|
|
565
|
+
const da = a.date || '';
|
|
566
|
+
const db = b.date || '';
|
|
567
|
+
if (da !== db) return da < db ? -1 : 1;
|
|
568
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
569
|
+
});
|
|
570
|
+
const lines = [LINKED_SNAPSHOTS_START];
|
|
571
|
+
if (sorted.length === 0) {
|
|
572
|
+
// Empty body — keep the marker pair compact (one blank line between markers) so the
|
|
573
|
+
// parser still finds them and the round-trip is byte-stable.
|
|
574
|
+
lines.push('');
|
|
575
|
+
} else {
|
|
576
|
+
for (const e of sorted) {
|
|
577
|
+
const meta = [];
|
|
578
|
+
if (e.date) meta.push(e.date);
|
|
579
|
+
if (e.branch) meta.push(`branch: ${e.branch}`);
|
|
580
|
+
const suffix = meta.length > 0 ? ` (${meta.join(', ')})` : '';
|
|
581
|
+
lines.push(`- ${e.name}${suffix}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
lines.push(LINKED_SNAPSHOTS_END);
|
|
585
|
+
return lines.join('\n');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// @cap-feature(feature:F-079) upsertLinkedSnapshotsBlock — pure string-level merge.
|
|
589
|
+
// Returns new file content with the linked_snapshots block updated. Idempotent.
|
|
590
|
+
/**
|
|
591
|
+
* @param {string} content - existing target file content
|
|
592
|
+
* @param {LinkedSnapshotEntry[]} entries - the FULL desired set (not a delta)
|
|
593
|
+
* @returns {string}
|
|
594
|
+
*/
|
|
595
|
+
function upsertLinkedSnapshotsBlock(content, entries) {
|
|
596
|
+
if (typeof content !== 'string') {
|
|
597
|
+
throw new TypeError('content must be a string');
|
|
598
|
+
}
|
|
599
|
+
const block = renderLinkedSnapshotsBlock(entries);
|
|
600
|
+
const existing = parseLinkedSnapshotsBlock(content);
|
|
601
|
+
if (existing) {
|
|
602
|
+
return content.slice(0, existing.startIdx) + block + content.slice(existing.endIdx);
|
|
603
|
+
}
|
|
604
|
+
// No block yet — append after the F-076 auto-block end-marker if present, else at EOF.
|
|
605
|
+
const autoEnd = content.indexOf(schema.AUTO_BLOCK_END_MARKER);
|
|
606
|
+
if (autoEnd !== -1) {
|
|
607
|
+
const after = autoEnd + schema.AUTO_BLOCK_END_MARKER.length;
|
|
608
|
+
// Insert with a leading blank line so it doesn't fuse onto the auto-block's end marker.
|
|
609
|
+
const sep = content.charAt(after) === '\n' ? '\n' : '\n\n';
|
|
610
|
+
return content.slice(0, after) + sep + block + (content.charAt(after) === '\n' ? '\n' : '') + content.slice(after);
|
|
611
|
+
}
|
|
612
|
+
// No auto-block either — append to EOF with a separating blank.
|
|
613
|
+
const trailer = content.endsWith('\n') ? '' : '\n';
|
|
614
|
+
return `${content}${trailer}\n${block}\n`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// -------- AC-4: Per-feature / platform linker (file IO) --------
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* @typedef {Object} LinkResult
|
|
621
|
+
* @property {boolean} updated
|
|
622
|
+
* @property {string} reason - 'wrote' | 'byte-identical-noop' | 'target-missing-stub-created'
|
|
623
|
+
* @property {string} path - absolute path of the target file
|
|
624
|
+
*/
|
|
625
|
+
|
|
626
|
+
// @cap-todo(ac:F-079/AC-4) linkSnapshotToFeature appends the snapshot to the per-feature
|
|
627
|
+
// memory file's linked_snapshots block. Idempotent: re-running with the same input is a
|
|
628
|
+
// no-op.
|
|
629
|
+
//
|
|
630
|
+
// @cap-decision(F-079/iter1) Stage-2 #5: return {updated, reason, path} instead of bare void
|
|
631
|
+
// so callers (pipeline, tests) can tell apart wrote / no-op / stub-created without
|
|
632
|
+
// re-reading the file. Mirrors writePlatformTopic's contract.
|
|
633
|
+
//
|
|
634
|
+
// @cap-decision(F-079/AC-4) Auto-create stub per-feature file when missing. The pipeline
|
|
635
|
+
// may run before any other memory has been written for a feature; we want the snapshot
|
|
636
|
+
// linkage to land regardless. Stub uses F-076 frontmatter (feature/topic/updated) +
|
|
637
|
+
// empty F-076 auto-block + linked_snapshots block. Schema-valid but minimal.
|
|
638
|
+
/**
|
|
639
|
+
* @param {string} projectRoot
|
|
640
|
+
* @param {string} featureId - F-NNN
|
|
641
|
+
* @param {string} topic - kebab-case topic for the per-feature file
|
|
642
|
+
* @param {LinkedSnapshotEntry[]} entries - FULL desired entry set
|
|
643
|
+
* @returns {LinkResult}
|
|
644
|
+
*/
|
|
645
|
+
function linkSnapshotsToFeature(projectRoot, featureId, topic, entries) {
|
|
646
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
647
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
648
|
+
}
|
|
649
|
+
if (!schema.FEATURE_ID_RE.test(featureId)) {
|
|
650
|
+
throw new TypeError(`featureId must match feature-id regex (got "${_safeForError(featureId)}")`);
|
|
651
|
+
}
|
|
652
|
+
if (!schema.TOPIC_RE.test(topic)) {
|
|
653
|
+
throw new TypeError(`topic must be kebab-case (got "${_safeForError(topic)}")`);
|
|
654
|
+
}
|
|
655
|
+
const featurePath = schema.getFeaturePath(projectRoot, featureId, topic);
|
|
656
|
+
let existing;
|
|
657
|
+
let stubCreated = false;
|
|
658
|
+
if (fs.existsSync(featurePath)) {
|
|
659
|
+
existing = fs.readFileSync(featurePath, 'utf8');
|
|
660
|
+
} else {
|
|
661
|
+
// Try to find any existing per-feature file for this featureId — topic may differ.
|
|
662
|
+
const featuresDir = path.join(projectRoot, schema.MEMORY_FEATURES_DIR);
|
|
663
|
+
const found = _findFeatureFileForId(featuresDir, featureId);
|
|
664
|
+
if (found) {
|
|
665
|
+
existing = fs.readFileSync(found, 'utf8');
|
|
666
|
+
// Honor the on-disk topic so we don't fork a sibling file.
|
|
667
|
+
const newPath = found;
|
|
668
|
+
const next = upsertLinkedSnapshotsBlock(existing, entries);
|
|
669
|
+
if (next === existing) {
|
|
670
|
+
return { updated: false, reason: 'byte-identical-noop', path: newPath };
|
|
671
|
+
}
|
|
672
|
+
_atomicWriteFile(newPath, next);
|
|
673
|
+
return { updated: true, reason: 'wrote', path: newPath };
|
|
674
|
+
}
|
|
675
|
+
// No file at all — synthesize a stub using the requested topic.
|
|
676
|
+
existing = _renderFeatureStub(featureId, topic);
|
|
677
|
+
stubCreated = true;
|
|
678
|
+
}
|
|
679
|
+
const next = upsertLinkedSnapshotsBlock(existing, entries);
|
|
680
|
+
if (!stubCreated && next === existing) {
|
|
681
|
+
return { updated: false, reason: 'byte-identical-noop', path: featurePath };
|
|
682
|
+
}
|
|
683
|
+
_atomicWriteFile(featurePath, next);
|
|
684
|
+
return {
|
|
685
|
+
updated: true,
|
|
686
|
+
reason: stubCreated ? 'target-missing-stub-created' : 'wrote',
|
|
687
|
+
path: featurePath,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// @cap-todo(ac:F-079/AC-4) linkSnapshotsToPlatform appends snapshots to a platform-topic
|
|
692
|
+
// memory file's linked_snapshots block. Same idempotent contract as the feature linker.
|
|
693
|
+
/**
|
|
694
|
+
* @param {string} projectRoot
|
|
695
|
+
* @param {string} topic - platform topic slug
|
|
696
|
+
* @param {LinkedSnapshotEntry[]} entries
|
|
697
|
+
* @returns {LinkResult}
|
|
698
|
+
*/
|
|
699
|
+
function linkSnapshotsToPlatform(projectRoot, topic, entries) {
|
|
700
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
701
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
702
|
+
}
|
|
703
|
+
_validateTopic(topic);
|
|
704
|
+
const platformPath = platformLib.getPlatformTopicPath(projectRoot, topic);
|
|
705
|
+
let existing;
|
|
706
|
+
let stubCreated = false;
|
|
707
|
+
if (fs.existsSync(platformPath)) {
|
|
708
|
+
existing = fs.readFileSync(platformPath, 'utf8');
|
|
709
|
+
} else {
|
|
710
|
+
existing = platformLib.renderPlatformTopic({ topic, updated: new Date().toISOString() });
|
|
711
|
+
stubCreated = true;
|
|
712
|
+
}
|
|
713
|
+
const next = upsertLinkedSnapshotsBlock(existing, entries);
|
|
714
|
+
if (!stubCreated && next === existing) {
|
|
715
|
+
return { updated: false, reason: 'byte-identical-noop', path: platformPath };
|
|
716
|
+
}
|
|
717
|
+
_atomicWriteFile(platformPath, next);
|
|
718
|
+
return {
|
|
719
|
+
updated: true,
|
|
720
|
+
reason: stubCreated ? 'target-missing-stub-created' : 'wrote',
|
|
721
|
+
path: platformPath,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Search .cap/memory/features/ for any file whose basename starts with `<featureId>-`.
|
|
727
|
+
* Returns the absolute path or null. Defensive against empty/missing dir.
|
|
728
|
+
* @param {string} featuresDir
|
|
729
|
+
* @param {string} featureId
|
|
730
|
+
* @returns {string|null}
|
|
731
|
+
*/
|
|
732
|
+
function _findFeatureFileForId(featuresDir, featureId) {
|
|
733
|
+
if (!fs.existsSync(featuresDir)) return null;
|
|
734
|
+
let entries;
|
|
735
|
+
try {
|
|
736
|
+
entries = fs.readdirSync(featuresDir);
|
|
737
|
+
} catch (_e) {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
const prefix = `${featureId}-`;
|
|
741
|
+
for (const name of entries) {
|
|
742
|
+
if (typeof name !== 'string') continue;
|
|
743
|
+
if (!name.endsWith('.md')) continue;
|
|
744
|
+
if (name.startsWith(prefix)) return path.join(featuresDir, name);
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Synthesize a minimal F-076-shaped per-feature memory file body. Schema-valid: feature +
|
|
751
|
+
* topic + updated + empty auto-block + empty manual region.
|
|
752
|
+
* @param {string} featureId
|
|
753
|
+
* @param {string} topic
|
|
754
|
+
* @returns {string}
|
|
755
|
+
*/
|
|
756
|
+
function _renderFeatureStub(featureId, topic) {
|
|
757
|
+
const updated = new Date().toISOString();
|
|
758
|
+
const titleCase = topic.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
759
|
+
return [
|
|
760
|
+
'---',
|
|
761
|
+
`feature: ${featureId}`,
|
|
762
|
+
`topic: ${topic}`,
|
|
763
|
+
`updated: ${updated}`,
|
|
764
|
+
'---',
|
|
765
|
+
'',
|
|
766
|
+
`# ${featureId}: ${titleCase}`,
|
|
767
|
+
'',
|
|
768
|
+
schema.AUTO_BLOCK_START_MARKER,
|
|
769
|
+
schema.AUTO_BLOCK_END_MARKER,
|
|
770
|
+
'',
|
|
771
|
+
'## Lessons',
|
|
772
|
+
'',
|
|
773
|
+
'<!-- Manual lessons go here. The auto-block above is regenerated by the memory pipeline. -->',
|
|
774
|
+
'',
|
|
775
|
+
].join('\n');
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// -------- AC-5: Migration heuristic (date + state-transitions) --------
|
|
779
|
+
//
|
|
780
|
+
// @cap-decision(F-079/iter1) Stage-2 #2 fix: AC-5 duplicate consolidated via Option A.
|
|
781
|
+
// The previous F-079 prototype shipped a pure helper `assignSnapshotByDate(date, transitions)`
|
|
782
|
+
// that duplicated the date-proximity branch of F-077's `classifySnapshot` in
|
|
783
|
+
// cap-memory-migrate.cjs:716-808. F-077's classifier is the SINGLE SOURCE OF TRUTH for
|
|
784
|
+
// "snapshot date heuristic" — it covers strictly more cases (frontmatter feature wins,
|
|
785
|
+
// date-proximity-single, date-proximity-multi, title F-NNN fallback, no-signal → unassigned)
|
|
786
|
+
// AND is the only one wired into the actual migration plan (`migrateMemory()`). Shipping a
|
|
787
|
+
// second copy created a maintenance hazard: a future tweak to one would silently diverge
|
|
788
|
+
// from the other.
|
|
789
|
+
//
|
|
790
|
+
// Option A chosen (delete F-079's helper) over Option B (refactor F-077 to delegate) because:
|
|
791
|
+
// 1. F-077's classifier has materially MORE behavior (frontmatter + multi-candidate +
|
|
792
|
+
// title-F-NNN), so it can't simply call assignSnapshotByDate as a primitive.
|
|
793
|
+
// 2. F-077's `classifySnapshot` is already pinned by 4 tests in cap-memory-migrate.test.cjs
|
|
794
|
+
// (frontmatter wins / date-proximity / title fallback / no-signal). Migrating F-079's
|
|
795
|
+
// boundary-determinism case (two transitions at the same timestamp → secondary sort by
|
|
796
|
+
// featureId) into the F-077 test file as a new `it()` keeps that pin alive.
|
|
797
|
+
// 3. F-077 already runs the migration end-to-end. AC-5 spec wording "Migration aus F-077
|
|
798
|
+
// MUSS Datum + State-Transitions ... nutzen" is already honored by F-077 — AC-5 is now
|
|
799
|
+
// a direct callout to the existing F-077 mechanism, not a separate F-079 deliverable.
|
|
800
|
+
//
|
|
801
|
+
// AC-5 deliverable for F-079: NONE in this module. F-077's classifier IS the implementation.
|
|
802
|
+
// The historical 12-snapshot adversarial fixture moves to tests/cap-memory-migrate.test.cjs
|
|
803
|
+
// (Option-A test migration, see Stage-2 #2).
|
|
804
|
+
|
|
805
|
+
// -------- AC-4 + AC-6: processSnapshots pipeline step --------
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* @typedef {Object} ProcessSnapshotsOptions
|
|
809
|
+
* @property {string=} now - ISO timestamp to use for stub `updated` fields (test seam)
|
|
810
|
+
* @property {Map<string,string>=} featureTopics - F-NNN -> existing topic slug; lets the caller
|
|
811
|
+
* override the default `_slugify(featureId)` (used to align stubs with F-077 outputs)
|
|
812
|
+
*/
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* @typedef {Object} ProcessSnapshotsResult
|
|
816
|
+
* @property {string[]} processed - snapshot names processed
|
|
817
|
+
* @property {string[]} writes - target file paths actually written
|
|
818
|
+
* @property {string[]} noops - target file paths that were byte-identical no-ops
|
|
819
|
+
* @property {{name:string, reason:string}[]} skipped - snapshots that could not be linked
|
|
820
|
+
* @property {{name:string, kind:'feature'|'platform'|'unassigned', target:string}[]} routes
|
|
821
|
+
*/
|
|
822
|
+
|
|
823
|
+
// @cap-feature(feature:F-079) processSnapshots — pipeline step that walks .cap/snapshots/*
|
|
824
|
+
// and ensures every snapshot is referenced from its target's linked_snapshots block.
|
|
825
|
+
// Idempotent: byte-identical re-write on second run.
|
|
826
|
+
//
|
|
827
|
+
// @cap-todo(ac:F-079/AC-4) processSnapshots groups snapshots by target (feature|platform|unassigned)
|
|
828
|
+
// and writes ONE upsert per target with the FULL set (sorted, deduped) — not per-snapshot
|
|
829
|
+
// appends. This is what makes the operation idempotent: the input set determines the output
|
|
830
|
+
// set deterministically.
|
|
831
|
+
//
|
|
832
|
+
// @cap-todo(ac:F-079/AC-6) Snapshots without `feature:` or `platform:` frontmatter (i.e. the
|
|
833
|
+
// classic orphan case) land in `.cap/memory/platform/snapshots-unassigned.md`. No snapshot
|
|
834
|
+
// is ever silently dropped.
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* @param {string} projectRoot
|
|
838
|
+
* @param {ProcessSnapshotsOptions=} options
|
|
839
|
+
* @returns {ProcessSnapshotsResult}
|
|
840
|
+
*/
|
|
841
|
+
function processSnapshots(projectRoot, options) {
|
|
842
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
843
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
844
|
+
}
|
|
845
|
+
const opts = options || {};
|
|
846
|
+
const featureTopics = opts.featureTopics instanceof Map ? opts.featureTopics : new Map();
|
|
847
|
+
|
|
848
|
+
/** @type {ProcessSnapshotsResult} */
|
|
849
|
+
const result = {
|
|
850
|
+
processed: [],
|
|
851
|
+
writes: [],
|
|
852
|
+
noops: [],
|
|
853
|
+
skipped: [],
|
|
854
|
+
routes: [],
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const names = listSnapshots(projectRoot);
|
|
858
|
+
if (names.length === 0) {
|
|
859
|
+
// AC: pipeline.processSnapshots on empty .cap/snapshots/ → no-op, no warn, no crash.
|
|
860
|
+
return result;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** @type {Map<string, LinkedSnapshotEntry[]>} keyed by `feature:F-NNN:<topic>` or `platform:<topic>` */
|
|
864
|
+
const byTarget = new Map();
|
|
865
|
+
/** @type {Map<string, {kind:'feature'|'platform', featureId?:string, topic:string}>} */
|
|
866
|
+
const targetMeta = new Map();
|
|
867
|
+
|
|
868
|
+
for (const name of names) {
|
|
869
|
+
let snap;
|
|
870
|
+
try {
|
|
871
|
+
snap = parseSnapshotFile(projectRoot, name);
|
|
872
|
+
} catch (_e) {
|
|
873
|
+
// Defensive: a malformed snapshot filename shouldn't kill the pipeline. Skip and move on.
|
|
874
|
+
result.skipped.push({ name, reason: 'parse-error' });
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (!snap) {
|
|
878
|
+
result.skipped.push({ name, reason: 'file-disappeared-during-walk' });
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
result.processed.push(name);
|
|
882
|
+
|
|
883
|
+
const fm = snap.frontmatter;
|
|
884
|
+
const dateStr = (fm && typeof fm.date === 'string') ? fm.date : null;
|
|
885
|
+
const branchStr = (fm && typeof fm.branch === 'string') ? fm.branch : null;
|
|
886
|
+
const entry = { name, date: dateStr, branch: branchStr };
|
|
887
|
+
|
|
888
|
+
if (fm && typeof fm.feature === 'string' && schema.FEATURE_ID_RE.test(fm.feature)) {
|
|
889
|
+
const fid = fm.feature;
|
|
890
|
+
const topic = featureTopics.get(fid) || _slugifyFromFeatureId(fid);
|
|
891
|
+
const key = `feature:${fid}:${topic}`;
|
|
892
|
+
if (!byTarget.has(key)) byTarget.set(key, []);
|
|
893
|
+
byTarget.get(key).push(entry);
|
|
894
|
+
targetMeta.set(key, { kind: 'feature', featureId: fid, topic });
|
|
895
|
+
result.routes.push({ name, kind: 'feature', target: `${fid}-${topic}.md` });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (fm && typeof fm.platform === 'string' && platformLib.PLATFORM_TOPIC_RE.test(fm.platform)) {
|
|
899
|
+
const topic = fm.platform;
|
|
900
|
+
const key = `platform:${topic}`;
|
|
901
|
+
if (!byTarget.has(key)) byTarget.set(key, []);
|
|
902
|
+
byTarget.get(key).push(entry);
|
|
903
|
+
targetMeta.set(key, { kind: 'platform', topic });
|
|
904
|
+
result.routes.push({ name, kind: 'platform', target: `${topic}.md` });
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
// AC-6: orphan → unassigned platform topic.
|
|
908
|
+
const key = `platform:${UNASSIGNED_SNAPSHOTS_TOPIC}`;
|
|
909
|
+
if (!byTarget.has(key)) byTarget.set(key, []);
|
|
910
|
+
byTarget.get(key).push(entry);
|
|
911
|
+
targetMeta.set(key, { kind: 'platform', topic: UNASSIGNED_SNAPSHOTS_TOPIC });
|
|
912
|
+
result.routes.push({ name, kind: 'unassigned', target: `${UNASSIGNED_SNAPSHOTS_TOPIC}.md` });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
for (const [key, entries] of byTarget.entries()) {
|
|
916
|
+
const meta = targetMeta.get(key);
|
|
917
|
+
if (!meta) continue;
|
|
918
|
+
if (meta.kind === 'feature' && meta.featureId && meta.topic) {
|
|
919
|
+
const linkResult = linkSnapshotsToFeature(projectRoot, meta.featureId, meta.topic, entries);
|
|
920
|
+
if (linkResult.updated) result.writes.push(linkResult.path);
|
|
921
|
+
else result.noops.push(linkResult.path);
|
|
922
|
+
} else if (meta.kind === 'platform' && meta.topic) {
|
|
923
|
+
const linkResult = linkSnapshotsToPlatform(projectRoot, meta.topic, entries);
|
|
924
|
+
if (linkResult.updated) result.writes.push(linkResult.path);
|
|
925
|
+
else result.noops.push(linkResult.path);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return result;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Derive a default kebab-slug topic from a feature id alone (e.g. "F-079" → "f-079").
|
|
934
|
+
* Used when the caller can't supply a richer topic from FEATURE-MAP.
|
|
935
|
+
* @param {string} featureId
|
|
936
|
+
*/
|
|
937
|
+
function _slugifyFromFeatureId(featureId) {
|
|
938
|
+
return featureId.toLowerCase();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// -------- Exports --------
|
|
942
|
+
|
|
943
|
+
module.exports = {
|
|
944
|
+
// Public API
|
|
945
|
+
resolveLinkageOptions,
|
|
946
|
+
injectLinkageFrontmatter,
|
|
947
|
+
parseSnapshotFile,
|
|
948
|
+
listSnapshots,
|
|
949
|
+
parseLinkedSnapshotsBlock,
|
|
950
|
+
renderLinkedSnapshotsBlock,
|
|
951
|
+
upsertLinkedSnapshotsBlock,
|
|
952
|
+
linkSnapshotsToFeature,
|
|
953
|
+
linkSnapshotsToPlatform,
|
|
954
|
+
processSnapshots,
|
|
955
|
+
// Constants
|
|
956
|
+
SNAPSHOTS_DIR,
|
|
957
|
+
LINKED_SNAPSHOTS_BLOCK_NAME,
|
|
958
|
+
LINKED_SNAPSHOTS_START,
|
|
959
|
+
LINKED_SNAPSHOTS_END,
|
|
960
|
+
SNAPSHOT_NAME_RE,
|
|
961
|
+
SNAPSHOT_DATE_WINDOW_HOURS,
|
|
962
|
+
UNASSIGNED_SNAPSHOTS_TOPIC,
|
|
963
|
+
};
|