@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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob matching for config paths: `**` matches any number of directories,
|
|
3
|
+
* `*` anything within one path segment, `?` one character, `{a,b}` either.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const cache = new Map ();
|
|
7
|
+
|
|
8
|
+
export function matchesGlob(path , glob ) {
|
|
9
|
+
let regex = cache.get(glob);
|
|
10
|
+
if (regex === undefined) {
|
|
11
|
+
regex = new RegExp(`^${globToRegex(glob)}$`);
|
|
12
|
+
cache.set(glob, regex);
|
|
13
|
+
}
|
|
14
|
+
return regex.test(path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function matchesAny(path , globs ) {
|
|
18
|
+
return globs.some((glob) => matchesGlob(path, glob));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function globToRegex(glob ) {
|
|
22
|
+
let out = "";
|
|
23
|
+
for (let i = 0; i < glob.length; i++) {
|
|
24
|
+
const char = glob[i] ?? "";
|
|
25
|
+
if (char === "*" && glob[i + 1] === "*") {
|
|
26
|
+
const slashAfter = glob[i + 2] === "/";
|
|
27
|
+
out += slashAfter ? "(?:.*/)?" : ".*";
|
|
28
|
+
i += slashAfter ? 2 : 1;
|
|
29
|
+
} else if (char === "*") {
|
|
30
|
+
out += "[^/]*";
|
|
31
|
+
} else if (char === "?") {
|
|
32
|
+
out += "[^/]";
|
|
33
|
+
} else if (char === "{") {
|
|
34
|
+
const close = glob.indexOf("}", i);
|
|
35
|
+
if (close === -1) {
|
|
36
|
+
out += "\\{";
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
out += `(?:${glob.slice(i + 1, close).split(",").map(globToRegex).join("|")})`;
|
|
40
|
+
i = close;
|
|
41
|
+
} else {
|
|
42
|
+
out += char.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { elementLines, isStatusLine, } from "./elements.js";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hashes cover design content only. Managed blocks and status lines are
|
|
7
|
+
* excluded, so bookkeeping by the CLI never makes a design look edited.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function sha256(text ) {
|
|
11
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Hash of a whole document's design content. */
|
|
15
|
+
export function designHash(doc ) {
|
|
16
|
+
const kept = doc.lines.filter((line, i) => !doc.inManaged[i] && (doc.inFence[i] || !isStatusLine(line)));
|
|
17
|
+
return sha256(normalize(kept));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hash of one element, independent of where it sits: heading levels are made
|
|
22
|
+
* relative to the element and a delta operation prefix is dropped, so an element
|
|
23
|
+
* hashes the same in a delta and after it is merged into a living document.
|
|
24
|
+
*/
|
|
25
|
+
export function elementHash(doc , element ) {
|
|
26
|
+
if (element.form === "row") return sha256(normalize(elementLines(doc, element)));
|
|
27
|
+
const kept = [];
|
|
28
|
+
for (let i = element.start; i < element.end; i++) {
|
|
29
|
+
const line = doc.lines[i] ?? "";
|
|
30
|
+
if (doc.inManaged[i] || (!doc.inFence[i] && isStatusLine(line))) continue;
|
|
31
|
+
kept.push(doc.inFence[i] ? line : relativeHeading(line, element.level, i === element.start));
|
|
32
|
+
}
|
|
33
|
+
return sha256(normalize(kept));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function relativeHeading(line , baseLevel , isOwnHeading ) {
|
|
37
|
+
const match = /^(#{1,6}) (.*)$/.exec(line);
|
|
38
|
+
if (match?.[1] === undefined || match[2] === undefined) return line;
|
|
39
|
+
const text = isOwnHeading ? match[2].replace(/^(ADDED|MODIFIED|REMOVED) /, "") : match[2];
|
|
40
|
+
return `${"#".repeat(match[1].length - baseLevel + 1)} ${text}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalize(lines ) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const raw of lines) {
|
|
46
|
+
const line = raw.trimEnd();
|
|
47
|
+
if (line === "" && (out.length === 0 || out[out.length - 1] === "")) continue;
|
|
48
|
+
out.push(line);
|
|
49
|
+
}
|
|
50
|
+
while (out.length > 0 && out[out.length - 1] === "") out.pop();
|
|
51
|
+
return out.join("\n");
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Element IDs: `<KIND>-<name>` or `<KIND>-<scope>.<name>`, named and never reused. */
|
|
2
|
+
|
|
3
|
+
export const KINDS = ["GOAL", "CTX", "ENT", "UC", "PORT", "ADP", "FLOW", "MOD", "RULE", "VIEW", "XC", "REQ", "SCN", "ADR"] ;
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
/** Kinds that describe code, and so carry a managed planned/built status. */
|
|
7
|
+
export const CODE_KINDS = ["ENT", "UC", "PORT", "ADP", "FLOW", "MOD"];
|
|
8
|
+
/** Kinds whose elements carry a managed status: code kinds and scenarios. */
|
|
9
|
+
export const STATUS_KINDS = [...CODE_KINDS, "SCN"];
|
|
10
|
+
/** Kinds that must name their context or capability as a scope. */
|
|
11
|
+
export const SCOPED_KINDS = ["ENT", "UC", "PORT", "ADP", "FLOW", "REQ", "SCN"];
|
|
12
|
+
|
|
13
|
+
const WORD = "[a-z0-9]+(?:-[a-z0-9]+)*";
|
|
14
|
+
export const ID_PATTERN = `(?:${KINDS.join("|")})-${WORD}(?:\\.${WORD})?`;
|
|
15
|
+
|
|
16
|
+
const EXACT = new RegExp(`^${ID_PATTERN}$`);
|
|
17
|
+
const ANYWHERE = new RegExp(`(?<![A-Za-z0-9_-])${ID_PATTERN}(?![A-Za-z0-9_-])`, "g");
|
|
18
|
+
|
|
19
|
+
export function isId(text ) {
|
|
20
|
+
return EXACT.test(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function kindOf(id ) {
|
|
24
|
+
return id.slice(0, id.indexOf("-")) ;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The context or capability an ID belongs to, if it has one. */
|
|
28
|
+
export function scopeOf(id ) {
|
|
29
|
+
const rest = id.slice(id.indexOf("-") + 1);
|
|
30
|
+
const dot = rest.indexOf(".");
|
|
31
|
+
return dot === -1 ? undefined : rest.slice(0, dot);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every ID mentioned in the text, once each, in order of first mention. */
|
|
35
|
+
export function findIds(text ) {
|
|
36
|
+
return [...new Set(text.match(ANYWHERE) ?? [])];
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** The append-only journal: one JSON object per line, written only by the CLI. */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/** Events other tools and skills record, with the fields each requires. */
|
|
11
|
+
export const RECORDED_EVENTS = {
|
|
12
|
+
rethink: ["level", "kind", "summary", "wrong", "caught_by"],
|
|
13
|
+
disposition: ["finding", "disposition", "reason", "review"],
|
|
14
|
+
review: ["review", "round", "blockers", "concerns"],
|
|
15
|
+
override: ["gate", "reason"],
|
|
16
|
+
bug: ["summary", "cause"],
|
|
17
|
+
verification: ["result", "summary"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const FIELD_VALUES = {
|
|
21
|
+
level: ["task", "change", "feature", "architecture", "vision"],
|
|
22
|
+
kind: ["defect", "discovery"],
|
|
23
|
+
disposition: ["fixed", "waived", "deferred", "rejected"],
|
|
24
|
+
review: ["design", "plan", "code"],
|
|
25
|
+
result: ["accepted", "sent-back"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function journalPath(folder ) {
|
|
29
|
+
return `${folder}/journal.jsonl`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseJournal(text ) {
|
|
33
|
+
const entries = [];
|
|
34
|
+
const badLines = [];
|
|
35
|
+
(text ?? "").split("\n").forEach((line, index) => {
|
|
36
|
+
if (line.trim() === "") return;
|
|
37
|
+
try {
|
|
38
|
+
const entry = JSON.parse(line) ;
|
|
39
|
+
if (typeof entry.event === "string" && typeof entry.at === "string") entries.push(entry);
|
|
40
|
+
else badLines.push(index + 1);
|
|
41
|
+
} catch {
|
|
42
|
+
badLines.push(index + 1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return { entries, badLines };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function serializeEntry(entry ) {
|
|
49
|
+
return `${JSON.stringify(entry)}\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Problems with an event a caller asks to record; empty when it can be recorded. */
|
|
53
|
+
export function recordedEventProblems(event , fields ) {
|
|
54
|
+
const required = RECORDED_EVENTS[event];
|
|
55
|
+
if (required === undefined) return [`unknown event "${event}"; recordable events: ${Object.keys(RECORDED_EVENTS).join(", ")}`];
|
|
56
|
+
const problems = required.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}`);
|
|
57
|
+
for (const [field, allowed] of Object.entries(FIELD_VALUES)) {
|
|
58
|
+
const value = fields[field];
|
|
59
|
+
if (value !== undefined && required.includes(field) && !allowed.includes(value)) problems.push(`${field} must be one of ${allowed.join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
return problems;
|
|
62
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { removeRow, upsertRow } from "../edits/managed_tables.js";
|
|
2
|
+
import { updateProgress } from "../edits/progress_block.js";
|
|
3
|
+
import { parseMarkdown } from "../markdown.js";
|
|
4
|
+
import { PATHS } from "../project/layout.js";
|
|
5
|
+
import { readChangesTable } from "../project/plan_parts.js";
|
|
6
|
+
import { readProgress } from "../project/progress.js";
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
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 , change , source , landed , files ) {
|
|
20
|
+
const text = (path ) => 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 = { 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 ) {
|
|
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.js";
|
|
2
|
+
import { CruzeError } from "../cruze_error.js";
|
|
3
|
+
import { setStatus } from "../edits/status_lines.js";
|
|
4
|
+
import { headingElements, statusOf, } from "../elements.js";
|
|
5
|
+
import { elementHash } from "../hashing.js";
|
|
6
|
+
import { STATUS_KINDS, kindOf, scopeOf } from "../ids.js";
|
|
7
|
+
import { findSection, parseMarkdown } from "../markdown.js";
|
|
8
|
+
import { readDelta, } from "../project/deltas.js";
|
|
9
|
+
import { PATHS, specPath } from "../project/layout.js";
|
|
10
|
+
import { readScope } from "../project/plan_parts.js";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import { REMOVED_MARK, capabilityKey } from "../validation/delta_rules.js";
|
|
14
|
+
import { appendBlock, relevel, removeElement, replaceElement } from "./text_ops.js";
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
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 , change , source ) {
|
|
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 ();
|
|
37
|
+
const text = (path ) => files.get(path) ?? view.snapshot.get(path) ?? "";
|
|
38
|
+
const plan = { 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 ,
|
|
78
|
+
source ,
|
|
79
|
+
entry ,
|
|
80
|
+
path ,
|
|
81
|
+
text ,
|
|
82
|
+
files ,
|
|
83
|
+
merged ,
|
|
84
|
+
plan ,
|
|
85
|
+
) {
|
|
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 , source , target , inScope ) {
|
|
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 , entries , text , plan ) {
|
|
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.js";
|
|
2
|
+
import { parseMarkdown, } from "../markdown.js";
|
|
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 , start , end , level ) {
|
|
11
|
+
const own = /^(#{1,6}) /.exec(doc.lines[start] ?? "")?.[1]?.length ?? level;
|
|
12
|
+
const shift = level - own;
|
|
13
|
+
const out = [];
|
|
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 , id , block ) {
|
|
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 , id ) {
|
|
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 , block , section ) {
|
|
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
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export const MANAGED_OPEN = "<!-- cruze:managed -->";
|
|
23
|
+
export const MANAGED_CLOSE = "<!-- /cruze:managed -->";
|
|
24
|
+
|
|
25
|
+
export function parseMarkdown(text ) {
|
|
26
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
27
|
+
const { frontmatter, bodyStart } = readFrontmatter(lines);
|
|
28
|
+
const inFence = [];
|
|
29
|
+
const inManaged = [];
|
|
30
|
+
const headings = [];
|
|
31
|
+
let fence = 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 , heading ) {
|
|
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 , title , level = 2) {
|
|
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 ) {
|
|
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 = parseYaml(lines.slice(1, end).join("\n"));
|
|
77
|
+
const frontmatter = parsed !== null && typeof parsed === "object" ? (parsed ) : {};
|
|
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
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
/** Tables found in lines [from, to), skipping fenced code. */
|
|
8
|
+
export function tablesIn(lines , inFence , from , to ) {
|
|
9
|
+
const tables = [];
|
|
10
|
+
let current = 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 ) {
|
|
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.js";
|
|
2
|
+
import { PATHS } from "./layout.js";
|
|
3
|
+
|
|
4
|
+
import { findWorkItem, } from "./work_items.js";
|
|
5
|
+
|
|
6
|
+
const NAMED = { 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 , ref ) {
|
|
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 , ref ) {
|
|
27
|
+
const [head = "", change] = ref.split("/");
|
|
28
|
+
const undated = (name ) => 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 , ref ) {
|
|
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, } from "../elements.js";
|
|
2
|
+
import { findSection, } from "../markdown.js";
|
|
3
|
+
|
|
4
|
+
/** One delta operation on a living element, with its nested scenarios when it is a requirement. */
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export function readDelta(doc ) {
|
|
20
|
+
const entries = [];
|
|
21
|
+
const capabilities = [];
|
|
22
|
+
for (const [title, section] of [["Spec delta", "spec"], ["Architecture delta", "architecture"]] ) {
|
|
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 ) {
|
|
38
|
+
const orphans = [];
|
|
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
|
+
}
|