@printpractical/cruze 0.0.1
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/LICENSE +201 -0
- package/README.md +167 -0
- package/bin/cruze.mjs +17 -0
- package/dist/adapters/inbound/cli/cli_context.js +75 -0
- package/dist/adapters/inbound/cli/commands/code.js +30 -0
- package/dist/adapters/inbound/cli/commands/documents.js +52 -0
- package/dist/adapters/inbound/cli/commands/init.js +23 -0
- package/dist/adapters/inbound/cli/commands/install.js +15 -0
- package/dist/adapters/inbound/cli/commands/journal.js +31 -0
- package/dist/adapters/inbound/cli/commands/progress.js +51 -0
- package/dist/adapters/inbound/cli/commands/review.js +18 -0
- package/dist/adapters/inbound/cli/commands/status.js +45 -0
- package/dist/adapters/inbound/cli/commands.js +36 -0
- package/dist/adapters/inbound/cli/parse_command.js +90 -0
- package/dist/adapters/inbound/cli/run_cli.js +50 -0
- package/dist/adapters/outbound/git_repository.js +57 -0
- package/dist/adapters/outbound/node_project_files.js +101 -0
- package/dist/adapters/outbound/package_bundle.js +56 -0
- package/dist/adapters/outbound/process_agent_runner.js +26 -0
- package/dist/adapters/outbound/system_clock.js +14 -0
- package/dist/adapters/outbound/terminal_prompter.js +22 -0
- package/dist/app/ports/agent_runner.js +11 -0
- package/dist/app/ports/bundle.js +22 -0
- package/dist/app/ports/clock.js +6 -0
- package/dist/app/ports/project_files.js +20 -0
- package/dist/app/ports/prompter.js +5 -0
- package/dist/app/ports/repository.js +13 -0
- package/dist/app/project_context.js +45 -0
- package/dist/app/use_cases/abandon_work.js +42 -0
- package/dist/app/use_cases/approve_artifact.js +146 -0
- package/dist/app/use_cases/check_code.js +90 -0
- package/dist/app/use_cases/init_project.js +59 -0
- package/dist/app/use_cases/install_skills.js +93 -0
- package/dist/app/use_cases/journal_events.js +48 -0
- package/dist/app/use_cases/land_change.js +113 -0
- package/dist/app/use_cases/new_project_doc.js +40 -0
- package/dist/app/use_cases/new_work_item.js +75 -0
- package/dist/app/use_cases/project_status.js +73 -0
- package/dist/app/use_cases/record_progress.js +101 -0
- package/dist/app/use_cases/run_review.js +71 -0
- package/dist/app/use_cases/validate_project.js +22 -0
- package/dist/domain/agent_skill_homes.js +25 -0
- package/dist/domain/approvals/evaluate.js +36 -0
- package/dist/domain/approvals/fingerprint.js +52 -0
- package/dist/domain/approvals/records.js +50 -0
- package/dist/domain/architecture_sections.js +23 -0
- package/dist/domain/check/check_source.js +64 -0
- package/dist/domain/check/imports.js +81 -0
- package/dist/domain/check/rust_imports.js +84 -0
- package/dist/domain/check/type_count.js +17 -0
- package/dist/domain/config.js +128 -0
- package/dist/domain/cruze_error.js +10 -0
- package/dist/domain/edits/agents_file.js +44 -0
- package/dist/domain/edits/managed_tables.js +60 -0
- package/dist/domain/edits/progress_block.js +33 -0
- package/dist/domain/edits/status_lines.js +52 -0
- package/dist/domain/elements.js +126 -0
- package/dist/domain/glob.js +46 -0
- package/dist/domain/hashing.js +52 -0
- package/dist/domain/ids.js +37 -0
- package/dist/domain/journal.js +62 -0
- package/dist/domain/land/bookkeeping.js +54 -0
- package/dist/domain/land/merge_plan.js +132 -0
- package/dist/domain/land/text_ops.js +66 -0
- package/dist/domain/markdown.js +79 -0
- package/dist/domain/markdown_tables.js +34 -0
- package/dist/domain/project/artifact_ref.js +42 -0
- package/dist/domain/project/deltas.js +49 -0
- package/dist/domain/project/layout.js +40 -0
- package/dist/domain/project/living_model.js +54 -0
- package/dist/domain/project/new_items.js +48 -0
- package/dist/domain/project/plan_parts.js +111 -0
- package/dist/domain/project/progress.js +43 -0
- package/dist/domain/project/project_view.js +72 -0
- package/dist/domain/project/roadmap.js +43 -0
- package/dist/domain/project/work_items.js +68 -0
- package/dist/domain/project_name.js +20 -0
- package/dist/domain/scaffold.js +29 -0
- package/dist/domain/skill.js +106 -0
- package/dist/domain/skill_links.js +103 -0
- package/dist/domain/status/next_step.js +119 -0
- package/dist/domain/status/verification.js +16 -0
- package/dist/domain/status/work_status.js +101 -0
- package/dist/domain/trace.js +44 -0
- package/dist/domain/validation/delta_rules.js +79 -0
- package/dist/domain/validation/element_rules.js +99 -0
- package/dist/domain/validation/living_rules.js +97 -0
- package/dist/domain/validation/plan_rules.js +105 -0
- package/dist/domain/validation/problem.js +21 -0
- package/dist/domain/validation/project_rules.js +99 -0
- package/dist/domain/validation/validate_project.js +31 -0
- package/dist/domain/validation/work_item_rules.js +144 -0
- package/dist/main.js +32 -0
- package/package.json +40 -0
- package/scripts/build.mjs +37 -0
- package/skills/about/SKILL.md +21 -0
- package/skills/architect/SKILL.md +43 -0
- package/skills/architect/feature.md +29 -0
- package/skills/architect/project.md +58 -0
- package/skills/architect/tweak.md +22 -0
- package/skills/architect/walkthrough.md +29 -0
- package/skills/behavioural-testing/SKILL.md +63 -0
- package/skills/behavioural-testing/examples.md +102 -0
- package/skills/behavioural-testing/fakes.md +30 -0
- package/skills/build/SKILL.md +62 -0
- package/skills/dependency-approval/SKILL.md +48 -0
- package/skills/domain-language/SKILL.md +37 -0
- package/skills/envision/SKILL.md +29 -0
- package/skills/envision/feature.md +22 -0
- package/skills/envision/project.md +20 -0
- package/skills/explore/SKILL.md +39 -0
- package/skills/formats/SKILL.md +84 -0
- package/skills/formats/reference/architecture.md +80 -0
- package/skills/formats/reference/config.md +57 -0
- package/skills/formats/reference/durable-docs.md +71 -0
- package/skills/formats/reference/specs.md +65 -0
- package/skills/formats/reference/work-items.md +100 -0
- package/skills/formats/templates/adr.md +7 -0
- package/skills/formats/templates/architecture.md +100 -0
- package/skills/formats/templates/change.md +29 -0
- package/skills/formats/templates/feature.md +49 -0
- package/skills/formats/templates/glossary.md +8 -0
- package/skills/formats/templates/roadmap.md +32 -0
- package/skills/formats/templates/spec.md +11 -0
- package/skills/formats/templates/standalone-change.md +46 -0
- package/skills/formats/templates/vision.md +37 -0
- package/skills/grilling/SKILL.md +66 -0
- package/skills/hexagonal-design/SKILL.md +119 -0
- package/skills/hexagonal-design/contracts.md +68 -0
- package/skills/hexagonal-design/fundamentals.md +50 -0
- package/skills/hexagonal-design/languages/cpp.md +37 -0
- package/skills/hexagonal-design/languages/rust.md +40 -0
- package/skills/hexagonal-design/runtime.md +48 -0
- package/skills/land/SKILL.md +43 -0
- package/skills/next/SKILL.md +30 -0
- package/skills/plan/SKILL.md +52 -0
- package/skills/plan/tasks.md +26 -0
- package/skills/plan/test-plan.md +14 -0
- package/skills/research/SKILL.md +41 -0
- package/skills/research/evaluation.md +27 -0
- package/skills/rethink/SKILL.md +53 -0
- package/skills/rethink/levels.md +45 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roles/SKILL.md +57 -0
- package/skills/roles/code-reviewer.md +55 -0
- package/skills/roles/design-reviewer.md +63 -0
- package/skills/roles/researcher.md +33 -0
- package/skills/roles/verifier.md +38 -0
- package/skills/triage/SKILL.md +35 -0
- package/skills/verify/SKILL.md +39 -0
- package/skills/verify/manual-test.md +23 -0
- package/src/adapters/inbound/cli/cli_context.ts +75 -0
- package/src/adapters/inbound/cli/commands/code.ts +30 -0
- package/src/adapters/inbound/cli/commands/documents.ts +52 -0
- package/src/adapters/inbound/cli/commands/init.ts +23 -0
- package/src/adapters/inbound/cli/commands/install.ts +15 -0
- package/src/adapters/inbound/cli/commands/journal.ts +31 -0
- package/src/adapters/inbound/cli/commands/progress.ts +51 -0
- package/src/adapters/inbound/cli/commands/review.ts +18 -0
- package/src/adapters/inbound/cli/commands/status.ts +45 -0
- package/src/adapters/inbound/cli/commands.ts +36 -0
- package/src/adapters/inbound/cli/parse_command.ts +90 -0
- package/src/adapters/inbound/cli/run_cli.ts +50 -0
- package/src/adapters/outbound/git_repository.ts +57 -0
- package/src/adapters/outbound/node_project_files.ts +101 -0
- package/src/adapters/outbound/package_bundle.ts +56 -0
- package/src/adapters/outbound/process_agent_runner.ts +26 -0
- package/src/adapters/outbound/system_clock.ts +14 -0
- package/src/adapters/outbound/terminal_prompter.ts +22 -0
- package/src/app/ports/agent_runner.ts +11 -0
- package/src/app/ports/bundle.ts +22 -0
- package/src/app/ports/clock.ts +6 -0
- package/src/app/ports/project_files.ts +20 -0
- package/src/app/ports/prompter.ts +5 -0
- package/src/app/ports/repository.ts +13 -0
- package/src/app/project_context.ts +45 -0
- package/src/app/use_cases/abandon_work.ts +42 -0
- package/src/app/use_cases/approve_artifact.ts +146 -0
- package/src/app/use_cases/check_code.ts +90 -0
- package/src/app/use_cases/init_project.ts +59 -0
- package/src/app/use_cases/install_skills.ts +93 -0
- package/src/app/use_cases/journal_events.ts +48 -0
- package/src/app/use_cases/land_change.ts +113 -0
- package/src/app/use_cases/new_project_doc.ts +40 -0
- package/src/app/use_cases/new_work_item.ts +75 -0
- package/src/app/use_cases/project_status.ts +73 -0
- package/src/app/use_cases/record_progress.ts +101 -0
- package/src/app/use_cases/run_review.ts +71 -0
- package/src/app/use_cases/validate_project.ts +22 -0
- package/src/domain/agent_skill_homes.ts +25 -0
- package/src/domain/approvals/evaluate.ts +36 -0
- package/src/domain/approvals/fingerprint.ts +52 -0
- package/src/domain/approvals/records.ts +50 -0
- package/src/domain/architecture_sections.ts +23 -0
- package/src/domain/check/check_source.ts +64 -0
- package/src/domain/check/imports.ts +81 -0
- package/src/domain/check/rust_imports.ts +84 -0
- package/src/domain/check/type_count.ts +17 -0
- package/src/domain/config.ts +128 -0
- package/src/domain/cruze_error.ts +10 -0
- package/src/domain/edits/agents_file.ts +44 -0
- package/src/domain/edits/managed_tables.ts +60 -0
- package/src/domain/edits/progress_block.ts +33 -0
- package/src/domain/edits/status_lines.ts +52 -0
- package/src/domain/elements.ts +126 -0
- package/src/domain/glob.ts +46 -0
- package/src/domain/hashing.ts +52 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/journal.ts +62 -0
- package/src/domain/land/bookkeeping.ts +54 -0
- package/src/domain/land/merge_plan.ts +132 -0
- package/src/domain/land/text_ops.ts +66 -0
- package/src/domain/markdown.ts +79 -0
- package/src/domain/markdown_tables.ts +34 -0
- package/src/domain/project/artifact_ref.ts +42 -0
- package/src/domain/project/deltas.ts +49 -0
- package/src/domain/project/layout.ts +40 -0
- package/src/domain/project/living_model.ts +54 -0
- package/src/domain/project/new_items.ts +48 -0
- package/src/domain/project/plan_parts.ts +111 -0
- package/src/domain/project/progress.ts +43 -0
- package/src/domain/project/project_view.ts +72 -0
- package/src/domain/project/roadmap.ts +43 -0
- package/src/domain/project/work_items.ts +68 -0
- package/src/domain/project_name.ts +20 -0
- package/src/domain/scaffold.ts +29 -0
- package/src/domain/skill.ts +106 -0
- package/src/domain/skill_links.ts +103 -0
- package/src/domain/status/next_step.ts +119 -0
- package/src/domain/status/verification.ts +16 -0
- package/src/domain/status/work_status.ts +101 -0
- package/src/domain/trace.ts +44 -0
- package/src/domain/validation/delta_rules.ts +79 -0
- package/src/domain/validation/element_rules.ts +99 -0
- package/src/domain/validation/living_rules.ts +97 -0
- package/src/domain/validation/plan_rules.ts +105 -0
- package/src/domain/validation/problem.ts +21 -0
- package/src/domain/validation/project_rules.ts +99 -0
- package/src/domain/validation/validate_project.ts +31 -0
- package/src/domain/validation/work_item_rules.ts +144 -0
- package/src/main.ts +32 -0
- package/templates/init/AGENTS.md +28 -0
- package/templates/init/CHANGELOG.md +8 -0
- package/templates/init/CLAUDE.md +1 -0
- package/templates/init/README.md +1 -0
- package/templates/init/ci.yml +23 -0
- package/templates/init/config.yaml +33 -0
- package/templates/init/gitattributes +2 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { removeRow, upsertRow } from "../edits/managed_tables.ts";
|
|
2
|
+
import { updateProgress } from "../edits/progress_block.ts";
|
|
3
|
+
import { parseMarkdown } from "../markdown.ts";
|
|
4
|
+
import { PATHS } from "../project/layout.ts";
|
|
5
|
+
import { readChangesTable } from "../project/plan_parts.ts";
|
|
6
|
+
import { readProgress } from "../project/progress.ts";
|
|
7
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
8
|
+
import type { WorkItem } from "../project/work_items.ts";
|
|
9
|
+
|
|
10
|
+
export interface Bookkeeping {
|
|
11
|
+
/** Whether this land finishes the work: a standalone change, or a feature's last change. */
|
|
12
|
+
finished: boolean;
|
|
13
|
+
/** The folder to move into the archive when finished. */
|
|
14
|
+
archive?: { from: string; to: string };
|
|
15
|
+
release: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Records the land in progress blocks, the roadmap status and, when a feature finishes, the feature map. */
|
|
19
|
+
export function applyBookkeeping(view: ProjectView, change: WorkItem, source: WorkItem, landed: string, files: Map<string, string>): Bookkeeping {
|
|
20
|
+
const text = (path: string): string | undefined => files.get(path) ?? view.snapshot.get(path);
|
|
21
|
+
files.set(change.path, updateProgress(text(change.path) ?? "", (progress) => (progress.landed = landed)));
|
|
22
|
+
|
|
23
|
+
let finished = true;
|
|
24
|
+
if (source !== change) {
|
|
25
|
+
const featureText = updateProgress(text(source.path) ?? "", (progress) => progress.changes.set(change.folderName, "landed"));
|
|
26
|
+
files.set(source.path, featureText);
|
|
27
|
+
const states = readProgress(parseMarkdown(featureText)).changes;
|
|
28
|
+
finished = readChangesTable(source.doc).every((row) => states.get(row.change) === "landed");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const release = releaseName(text(PATHS.roadmap));
|
|
32
|
+
const slug = typeof source.doc.frontmatter?.["roadmap"] === "string" ? source.doc.frontmatter["roadmap"] : undefined;
|
|
33
|
+
const roadmap = text(PATHS.roadmap);
|
|
34
|
+
if (slug !== undefined && roadmap !== undefined && roadmap.includes("## Status")) {
|
|
35
|
+
files.set(PATHS.roadmap, upsertRow(roadmap, "Status", [slug, source.folderName, finished ? "landed" : "building"]));
|
|
36
|
+
}
|
|
37
|
+
const vision = text(PATHS.vision);
|
|
38
|
+
if (finished && source.kind === "feature" && slug !== undefined && vision !== undefined && vision.includes("### Implemented")) {
|
|
39
|
+
const { text: withoutFuture, removed } = removeRow(vision, "Future", slug);
|
|
40
|
+
const summary = removed?.[1] ?? String(source.doc.frontmatter?.["title"] ?? slug);
|
|
41
|
+
files.set(PATHS.vision, upsertRow(withoutFuture, "Implemented", [slug, release, summary]));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result: Bookkeeping = { finished, release };
|
|
45
|
+
if (finished) result.archive = { from: source.folder, to: `${PATHS.archiveDir}/${source.folderName}` };
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The release in progress: the roadmap's `## Release` line up to its colon. */
|
|
50
|
+
export function releaseName(roadmap: string | undefined): string {
|
|
51
|
+
if (roadmap === undefined) return "unreleased";
|
|
52
|
+
const line = roadmap.split("## Release")[1]?.split("\n").find((l) => l.trim() !== "");
|
|
53
|
+
return line?.split(":")[0]?.trim() || "unreleased";
|
|
54
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { sectionForKind } from "../architecture_sections.ts";
|
|
2
|
+
import { CruzeError } from "../cruze_error.ts";
|
|
3
|
+
import { setStatus } from "../edits/status_lines.ts";
|
|
4
|
+
import { headingElements, statusOf, type Element } from "../elements.ts";
|
|
5
|
+
import { elementHash } from "../hashing.ts";
|
|
6
|
+
import { STATUS_KINDS, kindOf, scopeOf } from "../ids.ts";
|
|
7
|
+
import { findSection, parseMarkdown } from "../markdown.ts";
|
|
8
|
+
import { readDelta, type DeltaEntry } from "../project/deltas.ts";
|
|
9
|
+
import { PATHS, specPath } from "../project/layout.ts";
|
|
10
|
+
import { readScope } from "../project/plan_parts.ts";
|
|
11
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
12
|
+
import type { WorkItem } from "../project/work_items.ts";
|
|
13
|
+
import { REMOVED_MARK, capabilityKey } from "../validation/delta_rules.ts";
|
|
14
|
+
import { appendBlock, relevel, removeElement, replaceElement } from "./text_ops.ts";
|
|
15
|
+
|
|
16
|
+
export interface MergePlan {
|
|
17
|
+
/** New text of every living document the merge changes. */
|
|
18
|
+
files: Map<string, string>;
|
|
19
|
+
/** The work's merge record after this land: element ID to its merged hash. */
|
|
20
|
+
merged: Record<string, string>;
|
|
21
|
+
mergedIds: string[];
|
|
22
|
+
built: string[];
|
|
23
|
+
removed: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Merges a change's work into the living docs. The first change of a feature to land merges
|
|
28
|
+
* the whole delta; elements it doesn't build arrive as planned, and later changes flip what
|
|
29
|
+
* they build to built. Text is replaced only when nobody else changed it since this work merged it.
|
|
30
|
+
*/
|
|
31
|
+
export function planMerge(view: ProjectView, change: WorkItem, source: WorkItem): MergePlan {
|
|
32
|
+
const scope = readScope(change.doc);
|
|
33
|
+
const inScope = new Set([...(scope?.delivers ?? []), ...(scope?.builds ?? [])]);
|
|
34
|
+
const delta = readDelta(source.doc);
|
|
35
|
+
const merged = { ...(view.approvals.get(source.folder)?.merged ?? {}) };
|
|
36
|
+
const files = new Map<string, string>();
|
|
37
|
+
const text = (path: string): string => files.get(path) ?? view.snapshot.get(path) ?? "";
|
|
38
|
+
const plan: MergePlan = { files, merged, mergedIds: [], built: [], removed: [] };
|
|
39
|
+
|
|
40
|
+
for (const op of delta.capabilities) {
|
|
41
|
+
const key = capabilityKey(op.capability);
|
|
42
|
+
if (merged[key] !== undefined || view.snapshot.has(specPath(op.capability))) continue;
|
|
43
|
+
const body = source.doc.lines.slice(op.start + 1, op.end).join("\n").trim();
|
|
44
|
+
files.set(specPath(op.capability), `# ${op.title}\n\n${body}\n`);
|
|
45
|
+
merged[key] = "created";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const entry of delta.entries) {
|
|
49
|
+
const { id } = entry.element;
|
|
50
|
+
const path = entry.section === "spec" ? specPath(scopeOf(id) ?? "") : PATHS.architecture;
|
|
51
|
+
if (entry.element.op === "REMOVED") {
|
|
52
|
+
if (scope?.removes.includes(id) === true) {
|
|
53
|
+
files.set(path, removeElement(text(path), id));
|
|
54
|
+
merged[id] = REMOVED_MARK;
|
|
55
|
+
plan.removed.push(id);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
mergeEntry(view, source, entry, path, text, files, merged, plan);
|
|
60
|
+
for (const target of [entry.element, ...entry.scenarios].filter((e) => STATUS_KINDS.includes(e.kind))) {
|
|
61
|
+
files.set(path, setStatus(text(path), target.id, statusAfterMerge(view, source, target, inScope)));
|
|
62
|
+
if (inScope.has(target.id)) plan.built.push(target.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const id of [...inScope].filter((id) => !delta.ids.has(id) && STATUS_KINDS.includes(kindOf(id)))) {
|
|
67
|
+
const living = view.living.elements.get(id);
|
|
68
|
+
if (living === undefined) continue;
|
|
69
|
+
files.set(living.path, setStatus(text(living.path), id, "built"));
|
|
70
|
+
plan.built.push(id);
|
|
71
|
+
}
|
|
72
|
+
verifyMerged(source, delta.entries, text, plan);
|
|
73
|
+
return plan;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mergeEntry(
|
|
77
|
+
view: ProjectView,
|
|
78
|
+
source: WorkItem,
|
|
79
|
+
entry: DeltaEntry,
|
|
80
|
+
path: string,
|
|
81
|
+
text: (path: string) => string,
|
|
82
|
+
files: Map<string, string>,
|
|
83
|
+
merged: Record<string, string>,
|
|
84
|
+
plan: MergePlan,
|
|
85
|
+
): void {
|
|
86
|
+
const { id } = entry.element;
|
|
87
|
+
const block = relevel(source.doc, entry.element.start, entry.element.end, entry.section === "spec" ? 2 : 3);
|
|
88
|
+
const deltaHash = elementHash(source.doc, entry.element);
|
|
89
|
+
const doc = parseMarkdown(text(path));
|
|
90
|
+
const living = headingElements(doc).find((e) => e.id === id);
|
|
91
|
+
const previous = merged[id];
|
|
92
|
+
if (living !== undefined) {
|
|
93
|
+
const livingHash = elementHash(doc, living);
|
|
94
|
+
if (previous !== undefined && previous !== REMOVED_MARK && livingHash !== previous) {
|
|
95
|
+
throw new CruzeError("merge-conflict", `${id} in ${path} changed since ${source.ref} merged it; rethink before landing`);
|
|
96
|
+
}
|
|
97
|
+
if (livingHash !== deltaHash) {
|
|
98
|
+
files.set(path, replaceElement(text(path), id, block));
|
|
99
|
+
plan.mergedIds.push(id);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
if (previous !== undefined && previous !== REMOVED_MARK) {
|
|
103
|
+
throw new CruzeError("merge-conflict", `${id} was removed from ${path} after ${source.ref} merged it; rethink before landing`);
|
|
104
|
+
}
|
|
105
|
+
const section = entry.section === "spec" ? undefined : sectionForKind(entry.element.kind);
|
|
106
|
+
if (section !== undefined && findSection(doc, section) === null) throw new CruzeError("merge-conflict", `${path} has no "## ${section}" section for ${id}`);
|
|
107
|
+
files.set(path, appendBlock(text(path), block, section));
|
|
108
|
+
plan.mergedIds.push(id);
|
|
109
|
+
}
|
|
110
|
+
merged[id] = deltaHash;
|
|
111
|
+
for (const scenario of entry.scenarios) merged[scenario.id] = elementHash(source.doc, scenario);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Built when this change builds it, or when it was built and its text is unchanged; planned otherwise. */
|
|
115
|
+
function statusAfterMerge(view: ProjectView, source: WorkItem, target: Element, inScope: Set<string>): "planned" | "built" {
|
|
116
|
+
if (inScope.has(target.id)) return "built";
|
|
117
|
+
const before = view.living.elements.get(target.id);
|
|
118
|
+
if (before === undefined || statusOf(before.doc, before.element) !== "built") return "planned";
|
|
119
|
+
return elementHash(before.doc, before.element) === elementHash(source.doc, target) ? "built" : "planned";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** After merging, every merged element must hash exactly as it did in the delta. */
|
|
123
|
+
function verifyMerged(source: WorkItem, entries: DeltaEntry[], text: (path: string) => string, plan: MergePlan): void {
|
|
124
|
+
for (const entry of entries.filter((e) => plan.mergedIds.includes(e.element.id))) {
|
|
125
|
+
const path = entry.section === "spec" ? specPath(scopeOf(entry.element.id) ?? "") : PATHS.architecture;
|
|
126
|
+
const doc = parseMarkdown(text(path));
|
|
127
|
+
const living = headingElements(doc).find((e) => e.id === entry.element.id);
|
|
128
|
+
if (living === undefined || elementHash(doc, living) !== elementHash(source.doc, entry.element)) {
|
|
129
|
+
throw new Error(`merge check failed: ${entry.element.id} in ${path} does not match the delta`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { headingElements, isStatusLine } from "../elements.ts";
|
|
2
|
+
import { parseMarkdown, type MarkdownDoc } from "../markdown.ts";
|
|
3
|
+
|
|
4
|
+
/** Line-level edits land makes to living documents. Every function takes and returns whole text. */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A delta block ready for a living document: headings shifted so the element's own heading
|
|
8
|
+
* sits at `level`, the operation prefix dropped, status lines removed, trailing blanks trimmed.
|
|
9
|
+
*/
|
|
10
|
+
export function relevel(doc: MarkdownDoc, start: number, end: number, level: number): string[] {
|
|
11
|
+
const own = /^(#{1,6}) /.exec(doc.lines[start] ?? "")?.[1]?.length ?? level;
|
|
12
|
+
const shift = level - own;
|
|
13
|
+
const out: string[] = [];
|
|
14
|
+
for (let i = start; i < end; i++) {
|
|
15
|
+
let line = doc.lines[i] ?? "";
|
|
16
|
+
if (!doc.inFence[i]) {
|
|
17
|
+
if (isStatusLine(line)) continue;
|
|
18
|
+
const heading = /^(#{1,6}) (.*)$/.exec(line);
|
|
19
|
+
if (heading?.[1] !== undefined && heading[2] !== undefined) {
|
|
20
|
+
const text = i === start ? heading[2].replace(/^(ADDED|MODIFIED|REMOVED) /, "") : heading[2];
|
|
21
|
+
line = `${"#".repeat(heading[1].length + shift)} ${text}`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
out.push(line);
|
|
25
|
+
}
|
|
26
|
+
while (out.length > 0 && out[out.length - 1]?.trim() === "") out.pop();
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Replaces an element's whole block, keeping one blank line before whatever follows. */
|
|
31
|
+
export function replaceElement(text: string, id: string, block: string[]): string {
|
|
32
|
+
const doc = parseMarkdown(text);
|
|
33
|
+
const element = headingElements(doc).find((e) => e.id === id);
|
|
34
|
+
if (element === undefined) throw new Error(`${id} is not in the document`);
|
|
35
|
+
const lines = [...doc.lines];
|
|
36
|
+
const follows = element.end < lines.length;
|
|
37
|
+
lines.splice(element.start, element.end - element.start, ...block, ...(follows ? [""] : []));
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function removeElement(text: string, id: string): string {
|
|
42
|
+
const doc = parseMarkdown(text);
|
|
43
|
+
const element = headingElements(doc).find((e) => e.id === id);
|
|
44
|
+
if (element === undefined) return text;
|
|
45
|
+
const lines = [...doc.lines];
|
|
46
|
+
lines.splice(element.start, element.end - element.start);
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Appends a block at the end of a `##` section, or at the end of the document when none is named. */
|
|
51
|
+
export function appendBlock(text: string, block: string[], section?: string): string {
|
|
52
|
+
const doc = parseMarkdown(text);
|
|
53
|
+
const lines = [...doc.lines];
|
|
54
|
+
let at = lines.length;
|
|
55
|
+
if (section !== undefined) {
|
|
56
|
+
const heading = doc.headings.find((h) => h.level === 2 && h.text === section);
|
|
57
|
+
if (heading === undefined) throw new Error(`the document has no "## ${section}" section`);
|
|
58
|
+
at = doc.headings.find((h) => h.line > heading.line && h.level <= 2)?.line ?? lines.length;
|
|
59
|
+
}
|
|
60
|
+
let insertAt = at;
|
|
61
|
+
while (insertAt > 0 && lines[insertAt - 1]?.trim() === "") insertAt--;
|
|
62
|
+
const trailing = at < lines.length ? [""] : [];
|
|
63
|
+
lines.splice(insertAt, at - insertAt, "", ...block, ...trailing);
|
|
64
|
+
const joined = lines.join("\n");
|
|
65
|
+
return joined.endsWith("\n") ? joined : `${joined}\n`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
|
|
3
|
+
/** A Markdown document split into lines, with the structure Cruze relies on. */
|
|
4
|
+
export interface MarkdownDoc {
|
|
5
|
+
lines: string[];
|
|
6
|
+
frontmatter: Record<string, unknown> | null;
|
|
7
|
+
/** First line after the frontmatter. */
|
|
8
|
+
bodyStart: number;
|
|
9
|
+
headings: Heading[];
|
|
10
|
+
/** True for lines inside a fenced code block, fences included. */
|
|
11
|
+
inFence: boolean[];
|
|
12
|
+
/** True for lines inside a managed block, markers included. */
|
|
13
|
+
inManaged: boolean[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Heading {
|
|
17
|
+
level: number;
|
|
18
|
+
text: string;
|
|
19
|
+
line: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MANAGED_OPEN = "<!-- cruze:managed -->";
|
|
23
|
+
export const MANAGED_CLOSE = "<!-- /cruze:managed -->";
|
|
24
|
+
|
|
25
|
+
export function parseMarkdown(text: string): MarkdownDoc {
|
|
26
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
27
|
+
const { frontmatter, bodyStart } = readFrontmatter(lines);
|
|
28
|
+
const inFence: boolean[] = [];
|
|
29
|
+
const inManaged: boolean[] = [];
|
|
30
|
+
const headings: Heading[] = [];
|
|
31
|
+
let fence: string | null = null;
|
|
32
|
+
let managed = false;
|
|
33
|
+
|
|
34
|
+
lines.forEach((line, index) => {
|
|
35
|
+
const fenceMatch = /^(```|~~~)/.exec(line.trimStart());
|
|
36
|
+
if (fence === null && fenceMatch?.[1] !== undefined && index >= bodyStart) {
|
|
37
|
+
fence = fenceMatch[1];
|
|
38
|
+
inFence.push(true);
|
|
39
|
+
} else if (fence !== null) {
|
|
40
|
+
inFence.push(true);
|
|
41
|
+
if (line.trimStart().startsWith(fence)) fence = null;
|
|
42
|
+
} else {
|
|
43
|
+
inFence.push(false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!inFence[index] && trimmed === MANAGED_OPEN) managed = true;
|
|
48
|
+
inManaged.push(managed);
|
|
49
|
+
if (!inFence[index] && trimmed === MANAGED_CLOSE) managed = false;
|
|
50
|
+
|
|
51
|
+
const heading = /^(#{1,6}) (.+?)\s*$/.exec(line);
|
|
52
|
+
if (heading?.[1] !== undefined && heading[2] !== undefined && !inFence[index] && index >= bodyStart) {
|
|
53
|
+
headings.push({ level: heading[1].length, text: heading[2], line: index });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return { lines, frontmatter, bodyStart, headings, inFence, inManaged };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The line range [start, end) of a heading's section: up to the next heading at the same or a higher level. */
|
|
60
|
+
export function sectionEnd(doc: MarkdownDoc, heading: Heading): number {
|
|
61
|
+
const next = doc.headings.find((h) => h.line > heading.line && h.level <= heading.level);
|
|
62
|
+
return next === undefined ? doc.lines.length : next.line;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The body lines of the first `##` section with this title, or null when absent. */
|
|
66
|
+
export function findSection(doc: MarkdownDoc, title: string, level = 2): { heading: Heading; start: number; end: number } | null {
|
|
67
|
+
const heading = doc.headings.find((h) => h.level === level && h.text === title);
|
|
68
|
+
if (heading === undefined) return null;
|
|
69
|
+
return { heading, start: heading.line + 1, end: sectionEnd(doc, heading) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readFrontmatter(lines: string[]): { frontmatter: Record<string, unknown> | null; bodyStart: number } {
|
|
73
|
+
if (lines[0] !== "---") return { frontmatter: null, bodyStart: 0 };
|
|
74
|
+
const end = lines.indexOf("---", 1);
|
|
75
|
+
if (end === -1) return { frontmatter: null, bodyStart: 0 };
|
|
76
|
+
const parsed: unknown = parseYaml(lines.slice(1, end).join("\n"));
|
|
77
|
+
const frontmatter = parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
78
|
+
return { frontmatter, bodyStart: end + 1 };
|
|
79
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Pipe tables: header cells and body rows, each row with its line number. */
|
|
2
|
+
export interface Table {
|
|
3
|
+
header: string[];
|
|
4
|
+
rows: Array<{ cells: string[]; line: number }>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Tables found in lines [from, to), skipping fenced code. */
|
|
8
|
+
export function tablesIn(lines: string[], inFence: boolean[], from: number, to: number): Table[] {
|
|
9
|
+
const tables: Table[] = [];
|
|
10
|
+
let current: Table | null = null;
|
|
11
|
+
for (let i = from; i < to; i++) {
|
|
12
|
+
const line = lines[i] ?? "";
|
|
13
|
+
const isRow = !inFence[i] && /^\s*\|.*\|\s*$/.test(line);
|
|
14
|
+
if (!isRow) {
|
|
15
|
+
current = null;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const cells = splitRow(line);
|
|
19
|
+
if (current === null) {
|
|
20
|
+
current = { header: cells, rows: [] };
|
|
21
|
+
tables.push(current);
|
|
22
|
+
} else if (cells.every((cell) => /^:?-{3,}:?$/.test(cell))) {
|
|
23
|
+
continue;
|
|
24
|
+
} else {
|
|
25
|
+
current.rows.push({ cells, line: i });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return tables;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function splitRow(line: string): string[] {
|
|
32
|
+
const inner = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
33
|
+
return inner.split("|").map((cell) => cell.trim());
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CruzeError } from "../cruze_error.ts";
|
|
2
|
+
import { PATHS } from "./layout.ts";
|
|
3
|
+
import type { ProjectView } from "./project_view.ts";
|
|
4
|
+
import { findWorkItem, type WorkItem } from "./work_items.ts";
|
|
5
|
+
|
|
6
|
+
const NAMED: Record<string, string> = { vision: PATHS.vision, architecture: PATHS.architecture, roadmap: PATHS.roadmap };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves what a person or skill names on the command line: `vision`, `architecture`,
|
|
10
|
+
* `roadmap`, a work item ref such as `2026-09-25-open-console/01-local-serial`, the same ref
|
|
11
|
+
* without its date (`open-console/local-serial`) when that names one active item, or a path.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveArtifact(view: ProjectView, ref: string): { path: string; item?: WorkItem } {
|
|
14
|
+
const named = NAMED[ref];
|
|
15
|
+
if (named !== undefined) {
|
|
16
|
+
if (!view.snapshot.has(named)) throw new CruzeError("not-found", `${named} does not exist yet`);
|
|
17
|
+
return { path: named };
|
|
18
|
+
}
|
|
19
|
+
const item = findWorkItem(view.items, ref.replace(/\/$/, "")) ?? bySlug(view.items, ref.replace(/\/$/, ""));
|
|
20
|
+
if (item !== undefined) return { path: item.path, item };
|
|
21
|
+
if (view.snapshot.has(ref)) return { path: ref };
|
|
22
|
+
throw new CruzeError("not-found", `nothing called "${ref}"; use vision, architecture, roadmap, a feature or change ID, or a path`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An active item named by its slug, without the date or the change number. */
|
|
26
|
+
function bySlug(items: WorkItem[], ref: string): WorkItem | undefined {
|
|
27
|
+
const [head = "", change] = ref.split("/");
|
|
28
|
+
const undated = (name: string): string => name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
29
|
+
const matches = items.filter((i) => !i.archived && i.kind !== "change" && undated(i.folderName) === head);
|
|
30
|
+
if (matches.length > 1) throw new CruzeError("ambiguous", `"${head}" names ${matches.map((m) => m.ref).join(" and ")}; use the full ID`);
|
|
31
|
+
const base = matches[0];
|
|
32
|
+
if (base === undefined || change === undefined) return base;
|
|
33
|
+
return items.find((i) => i.featureRef === base.ref && (i.folderName === change || i.folderName.replace(/^\d\d-/, "") === change));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolves a change: a feature's change or a standalone change, never archived. */
|
|
37
|
+
export function resolveChange(view: ProjectView, ref: string): WorkItem {
|
|
38
|
+
const { item } = resolveArtifact(view, ref);
|
|
39
|
+
if (item === undefined || item.kind === "feature") throw new CruzeError("not-a-change", `"${ref}" is not a change`);
|
|
40
|
+
if (item.archived) throw new CruzeError("archived", `${item.ref} has already landed`);
|
|
41
|
+
return item;
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { capabilityOps, headingElements, type CapabilityOp, type Element } from "../elements.ts";
|
|
2
|
+
import { findSection, type MarkdownDoc } from "../markdown.ts";
|
|
3
|
+
|
|
4
|
+
/** One delta operation on a living element, with its nested scenarios when it is a requirement. */
|
|
5
|
+
export interface DeltaEntry {
|
|
6
|
+
element: Element;
|
|
7
|
+
section: "spec" | "architecture";
|
|
8
|
+
/** Scenario elements nested under a requirement operation. */
|
|
9
|
+
scenarios: Element[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Delta {
|
|
13
|
+
entries: DeltaEntry[];
|
|
14
|
+
capabilities: CapabilityOp[];
|
|
15
|
+
/** Every ID a delta defines or changes, including nested scenarios. */
|
|
16
|
+
ids: Set<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readDelta(doc: MarkdownDoc): Delta {
|
|
20
|
+
const entries: DeltaEntry[] = [];
|
|
21
|
+
const capabilities: CapabilityOp[] = [];
|
|
22
|
+
for (const [title, section] of [["Spec delta", "spec"], ["Architecture delta", "architecture"]] as const) {
|
|
23
|
+
const range = findSection(doc, title);
|
|
24
|
+
if (range === null) continue;
|
|
25
|
+
capabilities.push(...capabilityOps(doc, range.start, range.end));
|
|
26
|
+
const elements = headingElements(doc, range.start, range.end);
|
|
27
|
+
for (const element of elements.filter((e) => e.op !== undefined)) {
|
|
28
|
+
const scenarios = elements.filter((e) => e.op === undefined && e.start > element.start && e.start < element.end);
|
|
29
|
+
entries.push({ element, section, scenarios });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const ids = new Set(entries.flatMap((entry) => [entry.element.id, ...entry.scenarios.map((s) => s.id)]));
|
|
33
|
+
return { entries, capabilities, ids };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Scenario elements that sit under no requirement operation: always a format error. */
|
|
37
|
+
export function orphanDeltaElements(doc: MarkdownDoc): Element[] {
|
|
38
|
+
const orphans: Element[] = [];
|
|
39
|
+
for (const title of ["Spec delta", "Architecture delta"]) {
|
|
40
|
+
const range = findSection(doc, title);
|
|
41
|
+
if (range === null) continue;
|
|
42
|
+
const elements = headingElements(doc, range.start, range.end);
|
|
43
|
+
const ops = elements.filter((e) => e.op !== undefined);
|
|
44
|
+
for (const element of elements.filter((e) => e.op === undefined)) {
|
|
45
|
+
if (!ops.some((op) => element.start > op.start && element.start < op.end)) orphans.push(element);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return orphans;
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Where Cruze documents live in a project, and the snapshot of them the domain works on. */
|
|
2
|
+
|
|
3
|
+
/** Project files by path, relative to the project root, with forward slashes. */
|
|
4
|
+
export type Snapshot = ReadonlyMap<string, string>;
|
|
5
|
+
|
|
6
|
+
export const PATHS = {
|
|
7
|
+
vision: "docs/vision.md",
|
|
8
|
+
glossary: "docs/glossary.md",
|
|
9
|
+
architecture: "docs/architecture.md",
|
|
10
|
+
roadmap: "docs/roadmap.md",
|
|
11
|
+
adrDir: "docs/adr",
|
|
12
|
+
specsDir: "docs/specs",
|
|
13
|
+
config: ".cruze/config.yaml",
|
|
14
|
+
featuresDir: ".cruze/features",
|
|
15
|
+
changesDir: ".cruze/changes",
|
|
16
|
+
archiveDir: ".cruze/archive",
|
|
17
|
+
notesDir: ".cruze/notes",
|
|
18
|
+
projectApprovals: ".cruze/approvals.json",
|
|
19
|
+
projectJournal: ".cruze/journal.jsonl",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
/** Directories whose files make up the snapshot. */
|
|
23
|
+
export const SNAPSHOT_ROOTS = ["docs", ".cruze"] as const;
|
|
24
|
+
|
|
25
|
+
export function specPath(capability: string): string {
|
|
26
|
+
return `${PATHS.specsDir}/${capability}.md`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function filesUnder(snapshot: Snapshot, dir: string): string[] {
|
|
30
|
+
const prefix = `${dir}/`;
|
|
31
|
+
return [...snapshot.keys()].filter((path) => path.startsWith(prefix)).sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Paths directly matching `<dir>/<name><suffix>`, one level deep. */
|
|
35
|
+
export function childFiles(snapshot: Snapshot, dir: string, suffix: string): string[] {
|
|
36
|
+
return filesUnder(snapshot, dir).filter((path) => {
|
|
37
|
+
const rest = path.slice(dir.length + 1);
|
|
38
|
+
return !rest.includes("/") && rest.endsWith(suffix);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { headingElements, rowElements, type Element } from "../elements.ts";
|
|
2
|
+
import { parseMarkdown, type MarkdownDoc } from "../markdown.ts";
|
|
3
|
+
import { PATHS, childFiles, type Snapshot } from "./layout.ts";
|
|
4
|
+
|
|
5
|
+
/** An element of the living docs, with the document it lives in. */
|
|
6
|
+
export interface LivingElement {
|
|
7
|
+
id: string;
|
|
8
|
+
path: string;
|
|
9
|
+
doc: MarkdownDoc;
|
|
10
|
+
element: Element;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The living docs: vision, architecture, specs and ADRs, indexed by element ID. */
|
|
14
|
+
export interface LivingModel {
|
|
15
|
+
docs: Map<string, MarkdownDoc>;
|
|
16
|
+
elements: Map<string, LivingElement>;
|
|
17
|
+
/** IDs defined more than once, with every place they appear. */
|
|
18
|
+
duplicates: Array<{ id: string; places: string[] }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function livingDocPaths(snapshot: Snapshot): string[] {
|
|
22
|
+
const fixed = [PATHS.vision, PATHS.architecture].filter((path) => snapshot.has(path));
|
|
23
|
+
return [...fixed, ...childFiles(snapshot, PATHS.specsDir, ".md"), ...childFiles(snapshot, PATHS.adrDir, ".md")];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildLivingModel(snapshot: Snapshot): LivingModel {
|
|
27
|
+
const docs = new Map<string, MarkdownDoc>();
|
|
28
|
+
const elements = new Map<string, LivingElement>();
|
|
29
|
+
const places = new Map<string, string[]>();
|
|
30
|
+
|
|
31
|
+
for (const path of livingDocPaths(snapshot)) {
|
|
32
|
+
const doc = parseMarkdown(snapshot.get(path) ?? "");
|
|
33
|
+
docs.set(path, doc);
|
|
34
|
+
for (const element of elementsOf(path, doc)) {
|
|
35
|
+
places.set(element.id, [...(places.get(element.id) ?? []), `${path}:${element.start + 1}`]);
|
|
36
|
+
if (!elements.has(element.id)) elements.set(element.id, { id: element.id, path, doc, element });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const duplicates = [...places].filter(([, at]) => at.length > 1).map(([id, at]) => ({ id, places: at }));
|
|
40
|
+
return { docs, elements, duplicates };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The ID an ADR file defines: `ADR-<file stem>`. */
|
|
44
|
+
export function adrId(path: string): string {
|
|
45
|
+
return `ADR-${path.slice(path.lastIndexOf("/") + 1, -".md".length)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function elementsOf(path: string, doc: MarkdownDoc): Element[] {
|
|
49
|
+
if (path.startsWith(`${PATHS.adrDir}/`)) {
|
|
50
|
+
return [{ id: adrId(path), kind: "ADR", title: doc.headings[0]?.text ?? "", form: "heading", level: 1, start: 0, end: doc.lines.length }];
|
|
51
|
+
}
|
|
52
|
+
if (path === PATHS.vision) return rowElements(doc).filter((element) => element.kind === "GOAL");
|
|
53
|
+
return headingElements(doc);
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CruzeError } from "../cruze_error.ts";
|
|
2
|
+
import { readChangesTable } from "./plan_parts.ts";
|
|
3
|
+
import type { ProjectView } from "./project_view.ts";
|
|
4
|
+
import type { WorkItem } from "./work_items.ts";
|
|
5
|
+
|
|
6
|
+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
|
|
8
|
+
export function requireSlug(slug: string): void {
|
|
9
|
+
if (!SLUG.test(slug)) throw new CruzeError("invalid-slug", `"${slug}" must be lowercase words joined by single hyphens`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A new feature or standalone change ID: the date plus the slug, with `-2`, `-3`… added
|
|
14
|
+
* on a clash with any live or archived work. IDs are never reused.
|
|
15
|
+
*/
|
|
16
|
+
export function allocateId(view: ProjectView, date: string, slug: string): string {
|
|
17
|
+
requireSlug(slug);
|
|
18
|
+
const taken = new Set(view.items.filter((item) => item.kind !== "change").map((item) => item.folderName));
|
|
19
|
+
const base = `${date}-${slug}`;
|
|
20
|
+
if (!taken.has(base)) return base;
|
|
21
|
+
for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The change folder name for a feature's change: the row of the feature's Changes table it
|
|
26
|
+
* names, by full name (`01-local-serial`) or slug (`local-serial`).
|
|
27
|
+
*/
|
|
28
|
+
export function changeRow(view: ProjectView, feature: WorkItem, name: string): ReturnType<typeof readChangesTable>[number] {
|
|
29
|
+
const rows = readChangesTable(feature.doc);
|
|
30
|
+
const row = rows.find((r) => r.change === name) ?? rows.find((r) => r.change.replace(/^\d\d-/, "") === name);
|
|
31
|
+
if (row === undefined) {
|
|
32
|
+
throw new CruzeError("change-not-listed", `${feature.ref} has no change "${name}" in its Changes table; add the row first`);
|
|
33
|
+
}
|
|
34
|
+
if (view.items.some((item) => item.featureRef === feature.ref && item.folderName === row.change)) {
|
|
35
|
+
throw new CruzeError("exists", `${feature.ref}/${row.change} already exists`);
|
|
36
|
+
}
|
|
37
|
+
return row;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fills the template's frontmatter, title and scope lines for a new document. */
|
|
41
|
+
export function fillTemplate(template: string, values: { roadmap?: string; delivers?: string[]; builds?: string[] }): string {
|
|
42
|
+
let text = template;
|
|
43
|
+
if (values.roadmap !== undefined) text = text.replace(/^roadmap: .*$/m, `roadmap: ${values.roadmap}`);
|
|
44
|
+
else text = text.replace(/^roadmap: <.*\n/m, "");
|
|
45
|
+
if (values.delivers !== undefined) text = text.replace(/^- Delivers: .*$/m, `- Delivers: ${values.delivers.join(", ")}`);
|
|
46
|
+
if (values.builds !== undefined) text = text.replace(/^- Builds: .*$/m, `- Builds: ${values.builds.join(", ")}`);
|
|
47
|
+
return text;
|
|
48
|
+
}
|