@smartmemory/compose 0.3.6-beta → 0.3.8
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/skills/compose/SKILL.md +42 -88
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +754 -347
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +7 -0
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/judgment-record.schema.json +425 -4
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
- package/dist/assets/arc-DY26UIVo.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
- package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
- package/dist/assets/graph-BNzKGvoy.js +1 -0
- package/dist/assets/graph-CI_1htl0.js +331 -0
- package/dist/assets/index-BEfrNBp8.js +123 -0
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BI8cXFPI.js +1 -0
- package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/min-vPHfnXcC.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +39 -4
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +1624 -195
- package/lib/canon-guard.js +245 -0
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +291 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +50 -15
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +66 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -14
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/hooks-status.js +32 -3
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +166 -0
- package/lib/judgment/store/records.js +184 -25
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +375 -22
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-writer.js +2842 -262
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +250 -54
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/mcp-enforcement.js +21 -35
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +428 -153
- package/lib/review-normalize.js +4 -0
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +391 -31
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +124 -24
- package/server/compose-mcp.js +91 -790
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +35 -3
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-BG3ngu8H.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
- package/dist/assets/arc-_v4hR_uD.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
- package/dist/assets/channel-B3U5wFAT.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
- package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
- package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-3ZH5eMcZ.js +0 -119
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/mobile-CaoXUwAr.js +0 -17
- package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
package/lib/review-normalize.js
CHANGED
|
@@ -144,6 +144,7 @@ export async function normalizeReviewResult(rawText, {
|
|
|
144
144
|
confidenceGate = 7,
|
|
145
145
|
lens = 'general',
|
|
146
146
|
repairFn,
|
|
147
|
+
onRepairUsed,
|
|
147
148
|
} = {}) {
|
|
148
149
|
// Step 1: Try direct JSON parse
|
|
149
150
|
let parsed = parseReviewJson(rawText);
|
|
@@ -158,6 +159,9 @@ export async function normalizeReviewResult(rawText, {
|
|
|
158
159
|
|
|
159
160
|
if (repairText) {
|
|
160
161
|
parsed = parseReviewJson(repairText);
|
|
162
|
+
// Only a parseable repair actually replaces the primary output — the
|
|
163
|
+
// caller uses this to decide which dispatch gets credited.
|
|
164
|
+
if (parsed && typeof onRepairUsed === 'function') onRepairUsed();
|
|
161
165
|
}
|
|
162
166
|
}
|
|
163
167
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roadmap-errors.js — typed errors for the ROADMAP.md write path
|
|
3
|
+
* (COMP-CONFLICT-MERGE).
|
|
4
|
+
*
|
|
5
|
+
* Lives in its own module so both `roadmap-preservers.js` (which raises the
|
|
6
|
+
* unbalanced-marker error) and `roadmap-residue.js` (which raises the prose-loss
|
|
7
|
+
* error) can import them without an import cycle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regenerating ROADMAP.md would drop hand-authored content the generator cannot
|
|
12
|
+
* account for. Carries the lost lines with their nearest heading and a
|
|
13
|
+
* remediation naming the exact marker to add. This is the typed-error shape
|
|
14
|
+
* IDEA-2 asks for, scoped to one writer.
|
|
15
|
+
*/
|
|
16
|
+
export class RoadmapProseLossError extends Error {
|
|
17
|
+
/** @param {Array<{lineNo:number, text:string, nearestHeading:string|null}>} lines */
|
|
18
|
+
constructor(lines) {
|
|
19
|
+
super(`ROADMAP.md regeneration would drop ${lines.length} hand-authored line(s)`);
|
|
20
|
+
this.name = 'RoadmapProseLossError';
|
|
21
|
+
this.code = 'ROADMAP_PROSE_LOSS';
|
|
22
|
+
this.lines = lines;
|
|
23
|
+
this.source_of_truth = 'ROADMAP.md';
|
|
24
|
+
this.remediation =
|
|
25
|
+
'Wrap the lost lines in <!-- preserved-section: <id> --> … <!-- /preserved-section -->, ' +
|
|
26
|
+
'or re-run with --protect to do that automatically, or --accept-loss to write anyway.';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A preserved-section open marker with no matching close. Left unfixed, the
|
|
32
|
+
* content it was meant to protect is silently discarded on regen — a typo'd
|
|
33
|
+
* close deletes exactly what it was guarding.
|
|
34
|
+
*/
|
|
35
|
+
export class RoadmapUnbalancedMarkerError extends Error {
|
|
36
|
+
/** @param {Array<{id:string, lineNo:number}>} markers */
|
|
37
|
+
constructor(markers) {
|
|
38
|
+
super(`ROADMAP.md has ${markers.length} unbalanced preserved-section marker(s)`);
|
|
39
|
+
this.name = 'RoadmapUnbalancedMarkerError';
|
|
40
|
+
this.code = 'ROADMAP_UNBALANCED_MARKER';
|
|
41
|
+
this.markers = markers;
|
|
42
|
+
this.source_of_truth = 'ROADMAP.md';
|
|
43
|
+
this.remediation =
|
|
44
|
+
'Add a matching <!-- /preserved-section --> close for each open marker listed above.';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Two preserved-sections share an id. They are keyed by id in a Map, so the
|
|
50
|
+
* second overwrites the first and its content is dropped on regen — and because
|
|
51
|
+
* the content is inside markers, the residue check treats it as safe and never
|
|
52
|
+
* flags the loss. Fail loud instead.
|
|
53
|
+
*/
|
|
54
|
+
export class RoadmapDuplicateMarkerError extends Error {
|
|
55
|
+
/** @param {Array<{id:string, lineNo:number}>} markers the DUPLICATE occurrences */
|
|
56
|
+
constructor(markers) {
|
|
57
|
+
super(`ROADMAP.md has ${markers.length} duplicate preserved-section id(s)`);
|
|
58
|
+
this.name = 'RoadmapDuplicateMarkerError';
|
|
59
|
+
this.code = 'ROADMAP_DUPLICATE_MARKER';
|
|
60
|
+
this.markers = markers;
|
|
61
|
+
this.source_of_truth = 'ROADMAP.md';
|
|
62
|
+
this.remediation =
|
|
63
|
+
'Give each preserved-section a unique id — a repeated id silently drops all but the last block.';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { FEATURE_CODE_RE_STRICT } from './feature-code.js';
|
|
16
16
|
import { splitPhaseHeading, PHASE_HEADING_TEXT_RE } from './roadmap-heading.js';
|
|
17
|
+
import { RoadmapUnbalancedMarkerError, RoadmapDuplicateMarkerError } from './roadmap-errors.js';
|
|
17
18
|
|
|
18
19
|
const FENCE_RE = /^```/;
|
|
19
20
|
const TABLE_HEADER_RE = /^\|.*\|$/;
|
|
@@ -187,17 +188,26 @@ export function readAnonymousRows(text) {
|
|
|
187
188
|
* Scan ROADMAP.md text and return a Map of preserved-section id → rawSource.
|
|
188
189
|
*
|
|
189
190
|
* rawSource includes both open and close markers and everything between.
|
|
190
|
-
* Markers inside fenced code blocks are ignored.
|
|
191
|
-
*
|
|
191
|
+
* Markers inside fenced code blocks are ignored.
|
|
192
|
+
*
|
|
193
|
+
* Unbalanced markers (open without matching close): with `strict: false`
|
|
194
|
+
* (default) they are excluded, matching every existing reader/parser call site.
|
|
195
|
+
* With `strict: true` — used by the CLI write path (COMP-CONFLICT-MERGE) — they
|
|
196
|
+
* raise `RoadmapUnbalancedMarkerError` instead of being silently dropped, because
|
|
197
|
+
* a typo'd close otherwise deletes exactly the content it was meant to protect.
|
|
192
198
|
*
|
|
193
199
|
* @param {string} text
|
|
200
|
+
* @param {object} [opts]
|
|
201
|
+
* @param {boolean} [opts.strict=false] raise on any unbalanced open marker
|
|
194
202
|
* @returns {Map<string, string>}
|
|
195
203
|
*/
|
|
196
|
-
export function readPreservedSections(text) {
|
|
204
|
+
export function readPreservedSections(text, { strict = false } = {}) {
|
|
197
205
|
const out = new Map();
|
|
198
206
|
let inFence = false;
|
|
199
207
|
let openId = null;
|
|
200
208
|
let openLineIdx = -1;
|
|
209
|
+
const unbalanced = [];
|
|
210
|
+
const duplicates = [];
|
|
201
211
|
const lines = text.split('\n');
|
|
202
212
|
|
|
203
213
|
// Track byte offsets to slice rawSource.
|
|
@@ -218,11 +228,16 @@ export function readPreservedSections(text) {
|
|
|
218
228
|
|
|
219
229
|
const openMatch = line.match(PRESERVED_OPEN_RE);
|
|
220
230
|
if (openMatch) {
|
|
231
|
+
// A new open before the previous one closed leaves the previous unbalanced.
|
|
232
|
+
if (openId !== null) unbalanced.push({ id: openId, lineNo: openLineIdx + 1 });
|
|
221
233
|
openId = openMatch[1];
|
|
222
234
|
openLineIdx = i;
|
|
223
235
|
continue;
|
|
224
236
|
}
|
|
225
237
|
if (openId !== null && PRESERVED_CLOSE_RE.test(line)) {
|
|
238
|
+
// A second balanced section with the same id overwrites the first in the
|
|
239
|
+
// Map — silently dropping it on regen. Record the duplicate occurrence.
|
|
240
|
+
if (out.has(openId)) duplicates.push({ id: openId, lineNo: openLineIdx + 1 });
|
|
226
241
|
const startOffset = lineOffsets[openLineIdx];
|
|
227
242
|
const endOffset = lineOffsets[i + 1] - 1; // exclude trailing newline of close marker line
|
|
228
243
|
out.set(openId, text.slice(startOffset, endOffset));
|
|
@@ -230,7 +245,12 @@ export function readPreservedSections(text) {
|
|
|
230
245
|
openLineIdx = -1;
|
|
231
246
|
}
|
|
232
247
|
}
|
|
233
|
-
|
|
248
|
+
if (openId !== null) unbalanced.push({ id: openId, lineNo: openLineIdx + 1 });
|
|
249
|
+
// Default: silently drop unbalanced opens / collapse duplicate ids (existing
|
|
250
|
+
// readers/parsers rely on this). Strict (CLI write path): raise, since either
|
|
251
|
+
// loses data.
|
|
252
|
+
if (strict && unbalanced.length > 0) throw new RoadmapUnbalancedMarkerError(unbalanced);
|
|
253
|
+
if (strict && duplicates.length > 0) throw new RoadmapDuplicateMarkerError(duplicates);
|
|
234
254
|
return out;
|
|
235
255
|
}
|
|
236
256
|
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roadmap-residue.js — detect hand-authored content a ROADMAP.md regeneration
|
|
3
|
+
* would drop (COMP-CONFLICT-MERGE).
|
|
4
|
+
*
|
|
5
|
+
* `compose roadmap generate` renders feature rows from feature.json and preserves
|
|
6
|
+
* curated content through a whitelist of six readers. Content matching none of
|
|
7
|
+
* them is dropped, and the row-level losslessness check (`checkRoundtrip`) is
|
|
8
|
+
* blind to prose — so a curated block can vanish while the tool reports
|
|
9
|
+
* "lossless: true".
|
|
10
|
+
*
|
|
11
|
+
* Residue = a line present in the base text, non-trivial, absent from the
|
|
12
|
+
* generated output, and not explained by a legitimate regeneration (a feature
|
|
13
|
+
* row rewritten, a table header re-emitted, a phase heading whose status flipped).
|
|
14
|
+
*
|
|
15
|
+
* Compare the base against the FINAL canonical bytes (after all generation
|
|
16
|
+
* passes), never the first pass: the duplicate-heading loss only manifests on a
|
|
17
|
+
* later pass, so a first-pass diff passes cleanly and then writes the lossy file.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { PRESERVED_OPEN_RE, PRESERVED_CLOSE_RE } from './roadmap-preservers.js';
|
|
21
|
+
import { splitPhaseHeading, PHASE_HEADING_TEXT_RE } from './roadmap-heading.js';
|
|
22
|
+
import { isFeatureCode } from './feature-code.js';
|
|
23
|
+
import { detectColumnLayout, splitRoadmapCells } from './roadmap-parser.js';
|
|
24
|
+
import { RoadmapProseLossError } from './roadmap-errors.js';
|
|
25
|
+
|
|
26
|
+
export { RoadmapProseLossError };
|
|
27
|
+
|
|
28
|
+
const FENCE_RE = /^```/;
|
|
29
|
+
const TABLE_ROW_RE = /^\|.+\|$/;
|
|
30
|
+
const HEADING_RE = /^#{1,6}\s/;
|
|
31
|
+
const DIVIDER_CELLS = (cells) => cells.length > 0 && cells.every((c) => /^[-:]+$/.test(c));
|
|
32
|
+
|
|
33
|
+
// buildKeyDocs (roadmap-gen.js) renders the `## Key Documents` table from feature
|
|
34
|
+
// designDocs: a `| Document | What it is |` header, a divider, then rows of the
|
|
35
|
+
// exact shape `| `<path>` | <CODE> design |`. Those lines are GENERATOR-OWNED — a
|
|
36
|
+
// designDoc change legitimately rewrites them — so they must never count as
|
|
37
|
+
// hand-authored loss, or `--protect` would freeze a stale generated link and
|
|
38
|
+
// default generate would refuse a legitimate regeneration.
|
|
39
|
+
//
|
|
40
|
+
// The row is generator-owned ONLY when its code is a REAL current feature code —
|
|
41
|
+
// identity, not shape. Shape alone is ambiguous: a curated row like
|
|
42
|
+
// `| `.../spec` | API-1 design |` looks identical to a generated one, so shape
|
|
43
|
+
// matching would silently drop it. Keying on the actual feature set means a
|
|
44
|
+
// curated row that references a non-feature "code" stays eligible (design path-2
|
|
45
|
+
// preserved), while a real feature's row — even after its designDoc changes — is
|
|
46
|
+
// correctly treated as the generator's. When the caller cannot supply the feature
|
|
47
|
+
// set (featureCodes = null), fall back to the shape check (looser, used only by
|
|
48
|
+
// pure-unit callers that control their inputs).
|
|
49
|
+
const KEY_DOCS_BLOCK = 'Key Documents';
|
|
50
|
+
const KEY_DOCS_HEADER_RE = /^\|\s*Document\s*\|\s*What it is\s*\|$/;
|
|
51
|
+
const TABLE_DIVIDER_RE = /^\|[\s|:-]+\|$/;
|
|
52
|
+
function isGeneratedKeyDocRow(text, featureCodes) {
|
|
53
|
+
const cells = splitRoadmapCells(text);
|
|
54
|
+
if (cells.length !== 2) return false;
|
|
55
|
+
if (!/^`[^`]+`$/.test(cells[0])) return false;
|
|
56
|
+
const m = cells[1].match(/^(\S+)\s+design$/);
|
|
57
|
+
if (!m || !isFeatureCode(m[1])) return false;
|
|
58
|
+
return featureCodes ? featureCodes.has(m[1]) : true;
|
|
59
|
+
}
|
|
60
|
+
const isGeneratedKeyDoc = (block, text, featureCodes) =>
|
|
61
|
+
block === KEY_DOCS_BLOCK &&
|
|
62
|
+
(KEY_DOCS_HEADER_RE.test(text) || TABLE_DIVIDER_RE.test(text) || isGeneratedKeyDocRow(text, featureCodes));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stable block id for a heading line. A `## ` phase heading keys on its TITLE
|
|
66
|
+
* with the status suffix stripped, so a status flip (`— PLANNED` → `— COMPLETE`)
|
|
67
|
+
* does not re-key the prose beneath it and manufacture false residue. Any other
|
|
68
|
+
* heading keys on its trimmed text.
|
|
69
|
+
*/
|
|
70
|
+
function blockIdForHeading(trimmed) {
|
|
71
|
+
const m = trimmed.match(PHASE_HEADING_TEXT_RE);
|
|
72
|
+
if (m && trimmed.startsWith('## ')) return splitPhaseHeading(m[1]).title;
|
|
73
|
+
return trimmed.replace(/^#{1,6}\s*/, '').trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Classify every line of a ROADMAP.md text, tracking the nearest heading (its
|
|
78
|
+
* stable block id) and table state. Only `kind: 'other'` lines — hand-authored
|
|
79
|
+
* prose, curated non-feature table rows — are eligible to be residue; a heading
|
|
80
|
+
* becomes eligible separately, when its phase no longer exists in the output.
|
|
81
|
+
*
|
|
82
|
+
* @returns {Array<{lineNo:number, text:string, block:string|null, kind:string}>}
|
|
83
|
+
* kind ∈ blank | fence | structural | heading | tableHeader | tableDivider | featureRow | other
|
|
84
|
+
*/
|
|
85
|
+
function classifyLines(text) {
|
|
86
|
+
const lines = text.split('\n');
|
|
87
|
+
const out = [];
|
|
88
|
+
let inFence = false;
|
|
89
|
+
let inPreserved = false;
|
|
90
|
+
let block = null;
|
|
91
|
+
let inTable = false;
|
|
92
|
+
let columnLayout = null;
|
|
93
|
+
let curatedTable = false; // a non-feature table the writer does NOT regenerate
|
|
94
|
+
const endTable = () => { inTable = false; columnLayout = null; curatedTable = false; };
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < lines.length; i++) {
|
|
97
|
+
const raw = lines[i];
|
|
98
|
+
const trimmed = raw.trim();
|
|
99
|
+
const push = (kind) => out.push({ lineNo: i + 1, text: trimmed, block, kind });
|
|
100
|
+
|
|
101
|
+
if (FENCE_RE.test(raw)) { inFence = !inFence; endTable(); push('fence'); continue; }
|
|
102
|
+
if (inFence) { push('other'); continue; } // fenced prose survives only if re-emitted; treat as eligible
|
|
103
|
+
|
|
104
|
+
// Preserved-section content is emitted verbatim by the writer, so it always
|
|
105
|
+
// survives — never residue. Marker lines and their content are structural.
|
|
106
|
+
// (Duplicate/unbalanced ids, which DO lose content, are caught by the
|
|
107
|
+
// strict preserved-section guard before this check runs.)
|
|
108
|
+
if (PRESERVED_OPEN_RE.test(raw)) { inPreserved = true; endTable(); push('structural'); continue; }
|
|
109
|
+
if (PRESERVED_CLOSE_RE.test(raw)) { inPreserved = false; push('structural'); continue; }
|
|
110
|
+
if (inPreserved) { push('structural'); continue; }
|
|
111
|
+
|
|
112
|
+
if (HEADING_RE.test(trimmed)) {
|
|
113
|
+
block = blockIdForHeading(trimmed);
|
|
114
|
+
endTable();
|
|
115
|
+
const level = (trimmed.match(/^#+/)[0]).length;
|
|
116
|
+
out.push({ lineNo: i + 1, text: trimmed, block, kind: 'heading', level });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (trimmed === '') { push('blank'); continue; }
|
|
120
|
+
if (trimmed === '---') { push('structural'); continue; }
|
|
121
|
+
|
|
122
|
+
if (TABLE_ROW_RE.test(trimmed)) {
|
|
123
|
+
const cells = splitRoadmapCells(trimmed);
|
|
124
|
+
if (!inTable && !DIVIDER_CELLS(cells)) {
|
|
125
|
+
// Header row. A feature/id-coded table (codeCol !== -1) is regenerated
|
|
126
|
+
// verbatim, so header + divider + rows are the generator's. A curated
|
|
127
|
+
// non-feature table (codeCol === -1, e.g. `| Document | ... |`) is
|
|
128
|
+
// hand-authored — its header and divider must travel with the block, so
|
|
129
|
+
// they are eligible, otherwise --protect wraps rows into a headerless,
|
|
130
|
+
// orphaned table and reports success.
|
|
131
|
+
inTable = true;
|
|
132
|
+
columnLayout = detectColumnLayout(cells);
|
|
133
|
+
curatedTable = !columnLayout || columnLayout.codeCol === -1;
|
|
134
|
+
push(curatedTable ? 'other' : 'tableHeader');
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (DIVIDER_CELLS(cells)) { push(curatedTable ? 'other' : 'tableDivider'); continue; }
|
|
138
|
+
// Data row: a resolvable feature code in a feature table is the generator's.
|
|
139
|
+
if (!curatedTable && columnLayout && isFeatureCode(cells[columnLayout.codeCol] ?? '')) {
|
|
140
|
+
push('featureRow'); continue;
|
|
141
|
+
}
|
|
142
|
+
push('other'); continue; // anonymous row / curated non-feature table row
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
endTable();
|
|
146
|
+
push('other');
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const keyOf = (block, text) => `${block ?? ''}\u0000${text}`;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Lines present in `baseText` that `candidateText` fails to carry over.
|
|
155
|
+
*
|
|
156
|
+
* Occurrence-aware: membership ("does this line appear anywhere") cannot detect
|
|
157
|
+
* losing ONE of two identical lines, which is exactly the duplicate-block case.
|
|
158
|
+
* Keyed by (containing block, line text) and compared by count, so losing one
|
|
159
|
+
* occurrence of a repeated heading or curated row is still residue.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} baseText
|
|
162
|
+
* @param {string} candidateText the FINAL canonical bytes that will be written
|
|
163
|
+
* @param {object} [opts]
|
|
164
|
+
* @param {Set<string>} [opts.featureCodes] current feature codes, so generator-owned
|
|
165
|
+
* Key Documents rows are recognised by identity rather than shape. Omit only from
|
|
166
|
+
* pure-unit callers that control their inputs.
|
|
167
|
+
* @returns {Array<{lineNo:number, text:string, nearestHeading:string|null}>}
|
|
168
|
+
*/
|
|
169
|
+
export function computeResidue(baseText, candidateText, opts = {}) {
|
|
170
|
+
const featureCodes = opts.featureCodes ?? null;
|
|
171
|
+
const isGenKeyDoc = (block, text) => isGeneratedKeyDoc(block, text, featureCodes);
|
|
172
|
+
const baseRecs = classifyLines(baseText);
|
|
173
|
+
const candRecs = classifyLines(candidateText);
|
|
174
|
+
|
|
175
|
+
// Headings are handled by LEVEL:
|
|
176
|
+
// - `#`/`##` (phase-level): membership by stable block id. The generator
|
|
177
|
+
// INTENTIONALLY collapses duplicate `##` phase headings (self-healing
|
|
178
|
+
// dedupe), so occurrence-counting them false-positives on normalization and
|
|
179
|
+
// would make --protect wrap a phase heading and destroy it. A phase heading
|
|
180
|
+
// is residue only when its whole section vanished; lost content beneath a
|
|
181
|
+
// collapsed duplicate is still caught as prose residue.
|
|
182
|
+
// - `###`+ (sub-headings): NOT deduped by the generator (preserved verbatim in
|
|
183
|
+
// their phase block), so losing one is real loss — occurrence-count by
|
|
184
|
+
// (block, text) to catch a bare sub-heading dropped when an identically
|
|
185
|
+
// named one survives elsewhere.
|
|
186
|
+
const candidatePhaseBlocks = new Set();
|
|
187
|
+
const candSubHeadingCount = new Map();
|
|
188
|
+
const candCount = new Map(); // ELIGIBLE prose lines keyed by (block, text)
|
|
189
|
+
for (const r of candRecs) {
|
|
190
|
+
if (r.kind === 'blank') continue;
|
|
191
|
+
if (r.kind === 'heading') {
|
|
192
|
+
if (r.level <= 2) candidatePhaseBlocks.add(r.block);
|
|
193
|
+
else candSubHeadingCount.set(keyOf(r.block, r.text), (candSubHeadingCount.get(keyOf(r.block, r.text)) ?? 0) + 1);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// Count ONLY eligible prose, symmetric with baseContentCount below. Counting
|
|
197
|
+
// generator-owned lines (feature rows, dividers, generated key-docs) here would
|
|
198
|
+
// let a regenerated feature row "pay for" a lost base prose line of identical
|
|
199
|
+
// text under the same block — a silent loss.
|
|
200
|
+
if (r.kind !== 'other' || isGenKeyDoc(r.block, r.text)) continue;
|
|
201
|
+
const k = keyOf(r.block, r.text);
|
|
202
|
+
candCount.set(k, (candCount.get(k) ?? 0) + 1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Pre-count base occurrences so, for N identical lines with N-D survivors, we
|
|
206
|
+
// can flag the FIRST D and keep the last N-D. The loss mechanism (Map collision
|
|
207
|
+
// in readPhaseBlocks) keeps the LAST occurrence, so the lost ones are the
|
|
208
|
+
// earliest — flagging those makes the reported line accurate and lets --protect
|
|
209
|
+
// wrap the occurrence that actually disappears.
|
|
210
|
+
const baseContentCount = new Map();
|
|
211
|
+
const baseSubCount = new Map();
|
|
212
|
+
for (const r of baseRecs) {
|
|
213
|
+
if (r.kind === 'other' && !isGenKeyDoc(r.block, r.text)) {
|
|
214
|
+
const k = keyOf(r.block, r.text);
|
|
215
|
+
baseContentCount.set(k, (baseContentCount.get(k) ?? 0) + 1);
|
|
216
|
+
} else if (r.kind === 'heading' && r.level >= 3) {
|
|
217
|
+
const k = keyOf(r.block, r.text);
|
|
218
|
+
baseSubCount.set(k, (baseSubCount.get(k) ?? 0) + 1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const flaggedContent = new Map();
|
|
223
|
+
const flaggedSub = new Map();
|
|
224
|
+
const residue = [];
|
|
225
|
+
const lose = (r) => residue.push({ lineNo: r.lineNo, text: r.text, nearestHeading: r.block });
|
|
226
|
+
const flagFirstD = (k, deficitBase, deficitCand, flagged) => {
|
|
227
|
+
const deficit = (deficitBase.get(k) ?? 0) - (deficitCand.get(k) ?? 0);
|
|
228
|
+
const seen = flagged.get(k) ?? 0;
|
|
229
|
+
if (seen < deficit) { flagged.set(k, seen + 1); return true; }
|
|
230
|
+
return false;
|
|
231
|
+
};
|
|
232
|
+
for (const r of baseRecs) {
|
|
233
|
+
if (r.kind === 'heading') {
|
|
234
|
+
if (r.level <= 2) {
|
|
235
|
+
if (!candidatePhaseBlocks.has(r.block)) lose(r); // whole section vanished
|
|
236
|
+
} else if (flagFirstD(keyOf(r.block, r.text), baseSubCount, candSubHeadingCount, flaggedSub)) {
|
|
237
|
+
lose(r);
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (r.kind !== 'other') continue;
|
|
242
|
+
if (isGenKeyDoc(r.block, r.text)) continue; // generator-owned, not hand-authored
|
|
243
|
+
if (flagFirstD(keyOf(r.block, r.text), baseContentCount, candCount, flaggedContent)) lose(r);
|
|
244
|
+
}
|
|
245
|
+
return residue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Section id from a heading: lowercase slug, letter-initial, `[a-z][a-z0-9-]*`. */
|
|
249
|
+
function sectionSlug(heading, used) {
|
|
250
|
+
let base = String(heading ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
251
|
+
if (!base || !/^[a-z]/.test(base)) base = base ? `s-${base}` : 'preserved';
|
|
252
|
+
let id = base;
|
|
253
|
+
let n = 2;
|
|
254
|
+
while (used.has(id)) id = `${base}-${n++}`;
|
|
255
|
+
used.add(id);
|
|
256
|
+
return id;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Rewrite `baseText`, wrapping each contiguous run of residue lines in
|
|
261
|
+
* `<!-- preserved-section: <id> -->` … `<!-- /preserved-section -->` markers with
|
|
262
|
+
* ids derived from the run's nearest heading. Regenerating from the result
|
|
263
|
+
* preserves the previously-dropped content. Turns the error into a one-command fix.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} baseText
|
|
266
|
+
* @param {Array<{lineNo:number, nearestHeading:string|null}>} residue
|
|
267
|
+
* @returns {string}
|
|
268
|
+
*/
|
|
269
|
+
export function protectResidue(baseText, residue) {
|
|
270
|
+
if (!residue || residue.length === 0) return baseText;
|
|
271
|
+
const lines = baseText.split('\n');
|
|
272
|
+
|
|
273
|
+
// Group residue into contiguous line runs.
|
|
274
|
+
const sorted = [...residue].sort((a, b) => a.lineNo - b.lineNo);
|
|
275
|
+
const runs = [];
|
|
276
|
+
for (const r of sorted) {
|
|
277
|
+
const last = runs[runs.length - 1];
|
|
278
|
+
if (last && r.lineNo === last.end + 1) last.end = r.lineNo;
|
|
279
|
+
else runs.push({ start: r.lineNo, end: r.lineNo, heading: r.nearestHeading });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Insert markers bottom-up so earlier line indices stay valid; within a run,
|
|
283
|
+
// splice the close (higher index) before the open (lower index). Seed the
|
|
284
|
+
// used-id set with ids ALREADY in the file, so a generated id can never collide
|
|
285
|
+
// with an existing preserved-section (which would then drop content on the next
|
|
286
|
+
// generate via the id-keyed Map).
|
|
287
|
+
const used = new Set();
|
|
288
|
+
for (const line of lines) {
|
|
289
|
+
const m = line.match(PRESERVED_OPEN_RE);
|
|
290
|
+
if (m) used.add(m[1]);
|
|
291
|
+
}
|
|
292
|
+
const ids = runs.map((run) => sectionSlug(run.heading, used));
|
|
293
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
294
|
+
const run = runs[i];
|
|
295
|
+
lines.splice(run.end, 0, '<!-- /preserved-section -->');
|
|
296
|
+
lines.splice(run.start - 1, 0, `<!-- preserved-section: ${ids[i]} -->`);
|
|
297
|
+
}
|
|
298
|
+
return lines.join('\n');
|
|
299
|
+
}
|