@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,45 @@
|
|
|
1
|
+
import { buildProjectView, } from "../domain/project/project_view.js";
|
|
2
|
+
import { SNAPSHOT_ROOTS } from "../domain/project/layout.js";
|
|
3
|
+
import { journalPath, serializeEntry, } from "../domain/journal.js";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
/** What the project use cases work with. */
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** Reads every document under docs/ and .cruze/ into a view. */
|
|
18
|
+
export async function loadView(files ) {
|
|
19
|
+
const snapshot = new Map ();
|
|
20
|
+
for (const root of SNAPSHOT_ROOTS) {
|
|
21
|
+
for (const path of await files.listFiles(root)) {
|
|
22
|
+
const text = await files.readText(path);
|
|
23
|
+
if (text !== undefined) snapshot.set(path, text);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return buildProjectView(snapshot);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A new view with some files replaced, for checking edits before writing them. */
|
|
30
|
+
export function withFiles(view , changes ) {
|
|
31
|
+
return buildProjectView(new Map([...view.snapshot, ...changes]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Appends an entry to the journal in `folder`, stamped with the time and the approver. */
|
|
35
|
+
export async function appendJournal(deps , folder , event , fields ) {
|
|
36
|
+
const entry = {
|
|
37
|
+
at: deps.clock.now().toISOString(),
|
|
38
|
+
event,
|
|
39
|
+
by: (await deps.repository.userName()) ?? "unknown",
|
|
40
|
+
cruze: deps.bundle.version,
|
|
41
|
+
...fields,
|
|
42
|
+
};
|
|
43
|
+
await deps.files.appendText(journalPath(folder), serializeEntry(entry));
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { readRows, upsertRow } from "../../domain/edits/managed_tables.js";
|
|
3
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.js";
|
|
4
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
5
|
+
import { appendJournal, loadView, } from "../project_context.js";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stops a feature or standalone change for good: archives its folder with the reason, marks it
|
|
16
|
+
* abandoned on the roadmap, and puts a feature back on the future list. Changes that already
|
|
17
|
+
* landed stay in the living docs.
|
|
18
|
+
*/
|
|
19
|
+
export async function abandonWork(deps , ref , reason ) {
|
|
20
|
+
if (reason.trim() === "") throw new CruzeError("missing-reason", "abandoning work needs a reason");
|
|
21
|
+
const view = await loadView(deps.files);
|
|
22
|
+
const { item } = resolveArtifact(view, ref);
|
|
23
|
+
if (item === undefined || item.archived) throw new CruzeError("not-found", `"${ref}" is not an active feature or standalone change`);
|
|
24
|
+
if (item.kind === "change") throw new CruzeError("not-abandonable", `${item.ref} belongs to a feature; remove it from the feature's Changes table through rethink instead`);
|
|
25
|
+
|
|
26
|
+
const slug = typeof item.doc.frontmatter?.["roadmap"] === "string" ? item.doc.frontmatter["roadmap"] : undefined;
|
|
27
|
+
const roadmap = view.snapshot.get(PATHS.roadmap);
|
|
28
|
+
if (slug !== undefined && roadmap !== undefined && roadmap.includes("## Status")) {
|
|
29
|
+
await deps.files.writeText(PATHS.roadmap, upsertRow(roadmap, "Status", [slug, item.folderName, "abandoned"]));
|
|
30
|
+
}
|
|
31
|
+
const report = { ref: item.ref, archivedTo: `${PATHS.archiveDir}/${item.folderName}` };
|
|
32
|
+
const vision = view.snapshot.get(PATHS.vision);
|
|
33
|
+
if (item.kind === "feature" && slug !== undefined && vision !== undefined && vision.includes("### Future")) {
|
|
34
|
+
if (!readRows(vision, "Future").some((row) => row[0] === slug)) {
|
|
35
|
+
await deps.files.writeText(PATHS.vision, upsertRow(vision, "Future", [slug, String(item.doc.frontmatter?.["title"] ?? slug), ""]));
|
|
36
|
+
}
|
|
37
|
+
report.futureList = slug;
|
|
38
|
+
}
|
|
39
|
+
await appendJournal(deps, item.folder, "abandon", { item: item.ref, reason: reason.trim() });
|
|
40
|
+
await deps.files.move(item.folder, report.archivedTo);
|
|
41
|
+
return report;
|
|
42
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { evaluateApproval } from "../../domain/approvals/evaluate.js";
|
|
2
|
+
import { fingerprint, isApprovable } from "../../domain/approvals/fingerprint.js";
|
|
3
|
+
import { latestApproval, serializeApprovals, withApproval, } from "../../domain/approvals/records.js";
|
|
4
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
5
|
+
import { updateProgress } from "../../domain/edits/progress_block.js";
|
|
6
|
+
import { setStatus, stampMissingStatuses } from "../../domain/edits/status_lines.js";
|
|
7
|
+
import { statusOf } from "../../domain/elements.js";
|
|
8
|
+
import { elementHash } from "../../domain/hashing.js";
|
|
9
|
+
import { parseMarkdown } from "../../domain/markdown.js";
|
|
10
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.js";
|
|
11
|
+
import { readDelta } from "../../domain/project/deltas.js";
|
|
12
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
13
|
+
import { isPlanned, readChangesTable } from "../../domain/project/plan_parts.js";
|
|
14
|
+
import { readProgress } from "../../domain/project/progress.js";
|
|
15
|
+
import { approvalFolder, approvalsPath, } from "../../domain/project/project_view.js";
|
|
16
|
+
import { featureOf } from "../../domain/project/work_items.js";
|
|
17
|
+
import { validateProject } from "../../domain/validation/validate_project.js";
|
|
18
|
+
import { appendJournal, loadView, withFiles, } from "../project_context.js";
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Records a person's approval of a document: its design hash and the hashes of everything
|
|
43
|
+
* upstream it relies on. Refuses invalid documents and documents whose upstream isn't approved.
|
|
44
|
+
*/
|
|
45
|
+
export async function approveArtifact(deps , ref , options = {}) {
|
|
46
|
+
let view = await loadView(deps.files);
|
|
47
|
+
const { path, item } = resolveArtifact(view, ref);
|
|
48
|
+
if (!isApprovable(view, path)) throw new CruzeError("not-approvable", `${path} is not something you approve; it changes through approved work`);
|
|
49
|
+
requireUpstreamApproved(view, path);
|
|
50
|
+
if (item?.kind === "feature" && readChangesTable(item.doc).length === 0) {
|
|
51
|
+
throw new CruzeError("not-designed", `${path} has no changes yet; architect designs and splits it before approval`);
|
|
52
|
+
}
|
|
53
|
+
if (item !== undefined && item.kind !== "feature" && !isPlanned(item.doc)) {
|
|
54
|
+
throw new CruzeError("not-planned", `${path} has no test plan or tasks yet; plan it before approving`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const edits = new Map ();
|
|
58
|
+
let text = view.snapshot.get(path) ?? "";
|
|
59
|
+
let stamped = [];
|
|
60
|
+
if (path === PATHS.architecture) ({ text, stamped } = stampMissingStatuses(text));
|
|
61
|
+
const replanned = options.replan ?? [];
|
|
62
|
+
if (replanned.length > 0 && path !== PATHS.architecture) throw new CruzeError("replan-architecture-only", "--replan applies to the architecture");
|
|
63
|
+
for (const id of replanned) {
|
|
64
|
+
const element = view.living.elements.get(id);
|
|
65
|
+
if (element?.path !== PATHS.architecture || statusOf(element.doc, element.element) !== "built") {
|
|
66
|
+
throw new CruzeError("not-built", `${id} is not a built element of ${PATHS.architecture}`);
|
|
67
|
+
}
|
|
68
|
+
text = setStatus(text, id, "planned");
|
|
69
|
+
}
|
|
70
|
+
let bound ;
|
|
71
|
+
if (item !== undefined && item.kind !== "feature" && readProgress(parseMarkdown(text)).branch === undefined) {
|
|
72
|
+
bound = (await deps.repository.currentBranch()) ?? undefined;
|
|
73
|
+
text = updateProgress(text, (progress) => {
|
|
74
|
+
if (bound !== undefined) progress.branch = bound;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (text !== view.snapshot.get(path)) edits.set(path, text);
|
|
78
|
+
view = withFiles(view, edits);
|
|
79
|
+
|
|
80
|
+
const errors = validateProject(view).filter((p) => p.path === path && p.severity === "error");
|
|
81
|
+
if (errors.length > 0) {
|
|
82
|
+
const listing = errors.map((p) => ` ${p.path}:${p.line ?? ""} ${p.message}`).join("\n");
|
|
83
|
+
throw new CruzeError("invalid", `${path} has ${errors.length} problem(s) to fix before approval:\n${listing}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { hash, upstream } = fingerprint(view, path, parseMarkdown(text));
|
|
87
|
+
const folder = approvalFolder(view, path);
|
|
88
|
+
const current = view.approvals.get(folder) ?? { version: 1, approvals: [], merged: {} };
|
|
89
|
+
const rebased = options.rebase ?? [];
|
|
90
|
+
const deltaIds = item === undefined || item.kind === "change" ? new Set () : readDelta(item.doc).ids;
|
|
91
|
+
requireRebased(view, deltaIds, latestApproval(current, path), rebased);
|
|
92
|
+
const merged = { ...current.merged };
|
|
93
|
+
for (const id of rebased) {
|
|
94
|
+
const living = view.living.elements.get(id);
|
|
95
|
+
if (living === undefined || (merged[id] === undefined && !deltaIds.has(id))) {
|
|
96
|
+
throw new CruzeError("not-in-delta", `${id} is not a living element that ${item?.ref ?? path} changes or merged`);
|
|
97
|
+
}
|
|
98
|
+
if (merged[id] !== undefined) merged[id] = elementHash(living.doc, living.element);
|
|
99
|
+
}
|
|
100
|
+
const approvals = withApproval({ ...current, merged }, {
|
|
101
|
+
artifact: path,
|
|
102
|
+
hash,
|
|
103
|
+
upstream,
|
|
104
|
+
approvedAt: deps.clock.now().toISOString(),
|
|
105
|
+
by: (await deps.repository.userName()) ?? "unknown",
|
|
106
|
+
});
|
|
107
|
+
for (const [changed, content] of edits) await deps.files.writeText(changed, content);
|
|
108
|
+
await deps.files.writeText(approvalsPath(folder), serializeApprovals(approvals));
|
|
109
|
+
await appendJournal(deps, folder, "approve", { artifact: path, hash, ...(replanned.length > 0 ? { replanned } : {}), ...(rebased.length > 0 ? { rebased } : {}) });
|
|
110
|
+
const report = { artifact: path, hash, upstream: Object.keys(upstream), stamped, replanned, rebased };
|
|
111
|
+
if (bound !== undefined) report.bound = bound;
|
|
112
|
+
return report;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A feature or standalone change whose delta changes an element must have been written against
|
|
117
|
+
* that element's current text. When the living element changed since the last approval, through
|
|
118
|
+
* a rethink or another land, the delta would overwrite that edit, so approval waits until the
|
|
119
|
+
* delta is reconciled and the element is named with --rebase.
|
|
120
|
+
*/
|
|
121
|
+
function requireRebased(view , deltaIds , previous , rebased ) {
|
|
122
|
+
if (previous === undefined) return;
|
|
123
|
+
const moved = [...deltaIds].filter((id) => {
|
|
124
|
+
const living = view.living.elements.get(id);
|
|
125
|
+
const before = previous.upstream[id];
|
|
126
|
+
return living !== undefined && before !== undefined && before !== elementHash(living.doc, living.element) && !rebased.includes(id);
|
|
127
|
+
});
|
|
128
|
+
if (moved.length > 0) {
|
|
129
|
+
throw new CruzeError(
|
|
130
|
+
"rebase-required",
|
|
131
|
+
`the living text of ${moved.join(", ")} changed since the last approval, and this work's delta changes ${moved.length === 1 ? "it" : "them"}. Rewrite the delta to keep that change, then approve with ${moved.map((id) => `--rebase ${id}`).join(" ")}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A change needs its feature approved; a feature or standalone change needs the architecture approved. */
|
|
137
|
+
function requireUpstreamApproved(view , path ) {
|
|
138
|
+
const item = view.items.find((i) => i.path === path);
|
|
139
|
+
if (item === undefined) return;
|
|
140
|
+
const upstream = item.kind === "change" ? featureOf(view.items, item)?.path : view.snapshot.has(PATHS.architecture) ? PATHS.architecture : undefined;
|
|
141
|
+
if (upstream === undefined) return;
|
|
142
|
+
const status = evaluateApproval(view, upstream);
|
|
143
|
+
if (status.state !== "approved") {
|
|
144
|
+
throw new CruzeError("upstream-not-approved", `approve ${upstream} first; it is ${status.state}${status.changed.length > 0 ? ` (${status.changed.join(", ")})` : ""}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { factsOf } from "../../domain/elements.js";
|
|
2
|
+
import { checkSource, } from "../../domain/check/check_source.js";
|
|
3
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
4
|
+
import { matchesAny } from "../../domain/glob.js";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import { resolveChange } from "../../domain/project/artifact_ref.js";
|
|
8
|
+
import { activeChange } from "../../domain/status/work_status.js";
|
|
9
|
+
import { traceScenarios, } from "../../domain/trace.js";
|
|
10
|
+
import { loadView, } from "../project_context.js";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Enforces layer rules and file budgets. Layer violations always fail; budget and
|
|
21
|
+
* module-map warnings fail only with `ci`.
|
|
22
|
+
*/
|
|
23
|
+
export async function checkCode(deps , options ) {
|
|
24
|
+
const view = await loadView(deps.files);
|
|
25
|
+
const config = requireConfig(view);
|
|
26
|
+
const sources = await readMatching(deps.files, config.source);
|
|
27
|
+
for (const file of options.files ?? []) {
|
|
28
|
+
if (!sources.has(file)) {
|
|
29
|
+
const text = await deps.files.readText(file);
|
|
30
|
+
if (text === undefined) throw new CruzeError("not-found", `${file} does not exist`);
|
|
31
|
+
if (matchesAny(file, config.source)) sources.set(file, text);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const only = options.files?.filter((file) => sources.has(file));
|
|
35
|
+
const findings = checkSource({ config, sources, modulePaths: modulePaths(view), ...(only === undefined ? {} : { only }) });
|
|
36
|
+
const failing = findings.filter((f) => f.severity === "error" || options.ci);
|
|
37
|
+
return { passed: failing.length === 0, checked: only?.length ?? sources.size, findings };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/** Scenario-to-test traceability for this branch's change, or for every built scenario with `all`. */
|
|
46
|
+
export async function traceTests(deps , options ) {
|
|
47
|
+
const view = await loadView(deps.files);
|
|
48
|
+
const config = requireConfig(view);
|
|
49
|
+
let change;
|
|
50
|
+
if (!options.all) {
|
|
51
|
+
change = options.change !== undefined ? resolveChange(view, options.change) : activeChange(view, await deps.repository.currentBranch());
|
|
52
|
+
if (change === undefined) throw new CruzeError("no-active-change", "no change is bound to this branch; name one with --change, or use --all");
|
|
53
|
+
}
|
|
54
|
+
const report = traceScenarios(view, await readMatching(deps.files, config.tests), change);
|
|
55
|
+
return { ...report, passed: report.missing.length === 0 && report.unknown.length === 0, scope: change?.ref ?? "all built scenarios" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requireConfig(view ) {
|
|
59
|
+
const config = view.config?.config;
|
|
60
|
+
if (config === null || config === undefined) throw new CruzeError("config", "fix .cruze/config.yaml first; run cruze validate for details");
|
|
61
|
+
return config;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Files matching any glob, read from the smallest directories that can contain them. */
|
|
65
|
+
async function readMatching(files , globs ) {
|
|
66
|
+
const result = new Map ();
|
|
67
|
+
const bases = [...new Set(globs.map(globBase))];
|
|
68
|
+
for (const base of bases) {
|
|
69
|
+
const listed = await files.listFiles(base === "" ? "." : base);
|
|
70
|
+
for (const path of listed) {
|
|
71
|
+
if (result.has(path) || !matchesAny(path, globs)) continue;
|
|
72
|
+
const text = await files.readText(path);
|
|
73
|
+
if (text !== undefined) result.set(path, text);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The directory part of a glob before its first wildcard. */
|
|
80
|
+
function globBase(glob ) {
|
|
81
|
+
const segments = glob.split("/");
|
|
82
|
+
const fixed = segments.slice(0, segments.findIndex((s) => /[*?{]/.test(s)));
|
|
83
|
+
return fixed.join("/");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function modulePaths(view ) {
|
|
87
|
+
return [...view.living.elements.values()]
|
|
88
|
+
.filter((e) => e.id.startsWith("MOD-"))
|
|
89
|
+
.flatMap((e) => (factsOf(e.doc, e.element).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")));
|
|
90
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { parseProjectName } from "../../domain/project_name.js";
|
|
2
|
+
import { INIT_SCAFFOLD, renderTemplate } from "../../domain/scaffold.js";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import { installSkills, } from "./install_skills.js";
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Gives a repository the Cruze starting files and installs the skills. Existing
|
|
31
|
+
* files are never overwritten, so running it in a brownfield repo is safe.
|
|
32
|
+
*/
|
|
33
|
+
export async function initProject(
|
|
34
|
+
deps ,
|
|
35
|
+
request ,
|
|
36
|
+
) {
|
|
37
|
+
const { files, bundle, prompter } = deps;
|
|
38
|
+
const project = parseProjectName(request.name ?? (await prompter.ask("Project name", request.defaultName)));
|
|
39
|
+
const values = {
|
|
40
|
+
project_name: project,
|
|
41
|
+
project_name_quoted: JSON.stringify(project),
|
|
42
|
+
cruze_version: bundle.version,
|
|
43
|
+
cruze_package: request.package ?? `@printpractical/cruze@${bundle.version}`,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const created = [];
|
|
47
|
+
const skipped = [];
|
|
48
|
+
for (const entry of INIT_SCAFFOLD) {
|
|
49
|
+
if (await files.exists(entry.path)) {
|
|
50
|
+
skipped.push(entry.path);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
await files.writeText(entry.path, renderTemplate(await bundle.template(entry.template), values));
|
|
54
|
+
created.push(entry.path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const skills = await installSkills({ files, bundle }, { agents: request.agents });
|
|
58
|
+
return { project, created, skipped, skills };
|
|
59
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
3
|
+
import { AGENT_SKILL_HOMES, SHARED_SKILLS_DIR, agentSkillHome, knownAgents } from "../../domain/agent_skill_homes.js";
|
|
4
|
+
import { SKILL_FILE, installedSkillName, isInstalledSkillName, skillProblems } from "../../domain/skill.js";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Replaces the project's Cruze skills with the bundled set. Skills are written to
|
|
22
|
+
* the shared Agent Skills directory, and linked for every agent the project uses.
|
|
23
|
+
* Skills without the Cruze prefix belong to the user and are never touched.
|
|
24
|
+
*/
|
|
25
|
+
export async function installSkills(
|
|
26
|
+
deps ,
|
|
27
|
+
request ,
|
|
28
|
+
) {
|
|
29
|
+
const { files, bundle } = deps;
|
|
30
|
+
const skills = await bundle.skills();
|
|
31
|
+
assertValid(skills);
|
|
32
|
+
|
|
33
|
+
const names = skills.map((skill) => installedSkillName(skill.folder));
|
|
34
|
+
const removed = await removeStaleSkills(files, SHARED_SKILLS_DIR, names);
|
|
35
|
+
for (const skill of skills) {
|
|
36
|
+
await writeSkill(files, skill);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const linked = [];
|
|
40
|
+
for (const agent of await agentsToLink(files, request.agents)) {
|
|
41
|
+
const home = agentSkillHome(agent);
|
|
42
|
+
if (home === undefined) continue;
|
|
43
|
+
await removeStaleSkills(files, home.skillsDir, []);
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
const linkPath = posix.join(home.skillsDir, name);
|
|
46
|
+
await files.link(linkPath, posix.relative(home.skillsDir, posix.join(SHARED_SKILLS_DIR, name)));
|
|
47
|
+
}
|
|
48
|
+
linked.push({ agent, dir: home.skillsDir });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { version: bundle.version, installed: names, removed, linked };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertValid(skills ) {
|
|
55
|
+
const problems = skills.flatMap((skill) => {
|
|
56
|
+
const skillFile = skill.files.find((file) => file.path === SKILL_FILE);
|
|
57
|
+
return skillFile === undefined ? [`${skill.folder}: ${SKILL_FILE} is missing`] : skillProblems(skill.folder, skillFile.text);
|
|
58
|
+
});
|
|
59
|
+
if (problems.length > 0) {
|
|
60
|
+
throw new CruzeError("invalid-bundle", `The bundled skills are invalid:\n${problems.join("\n")}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function writeSkill(files , skill ) {
|
|
65
|
+
const dir = posix.join(SHARED_SKILLS_DIR, installedSkillName(skill.folder));
|
|
66
|
+
await files.remove(dir);
|
|
67
|
+
for (const file of skill.files) {
|
|
68
|
+
await files.writeText(posix.join(dir, file.path), file.text);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Removes Cruze skills in `dir` that are not in `keep`, returning their names. */
|
|
73
|
+
async function removeStaleSkills(files , dir , keep ) {
|
|
74
|
+
const stale = (await files.listEntries(dir)).filter((name) => isInstalledSkillName(name) && !keep.includes(name));
|
|
75
|
+
for (const name of stale) {
|
|
76
|
+
await files.remove(posix.join(dir, name));
|
|
77
|
+
}
|
|
78
|
+
return stale;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function agentsToLink(files , requested ) {
|
|
82
|
+
const unknown = requested.filter((agent) => agentSkillHome(agent) === undefined);
|
|
83
|
+
if (unknown.length > 0) {
|
|
84
|
+
throw new CruzeError("unknown-agent", `Unknown agent: ${unknown.join(", ")}. Known agents: ${knownAgents().join(", ")}.`);
|
|
85
|
+
}
|
|
86
|
+
const agents = new Set(requested);
|
|
87
|
+
for (const home of AGENT_SKILL_HOMES) {
|
|
88
|
+
for (const marker of home.markers) {
|
|
89
|
+
if (await files.exists(marker)) agents.add(home.agent);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [...agents];
|
|
93
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { recordedEventProblems, } from "../../domain/journal.js";
|
|
3
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.js";
|
|
4
|
+
import { appendJournal, loadView, } from "../project_context.js";
|
|
5
|
+
|
|
6
|
+
/** Records an event a skill observed: a rethink, a review round, a disposition, an override or a bug. */
|
|
7
|
+
export async function recordEvent(deps , event , fields , item ) {
|
|
8
|
+
const problems = recordedEventProblems(event, fields);
|
|
9
|
+
if (problems.length > 0) throw new CruzeError("invalid-event", problems.join("; "));
|
|
10
|
+
let folder = ".cruze";
|
|
11
|
+
if (item !== undefined) {
|
|
12
|
+
const resolved = resolveArtifact(await loadView(deps.files), item).item;
|
|
13
|
+
if (resolved === undefined) throw new CruzeError("not-found", `"${item}" is not a feature or change`);
|
|
14
|
+
folder = resolved.folder;
|
|
15
|
+
fields = { ...fields, item: resolved.ref };
|
|
16
|
+
}
|
|
17
|
+
return appendJournal(deps, folder, event, { ...fields, cruze: deps.bundle.version });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function listEvents(deps , event ) {
|
|
21
|
+
const { journal } = await loadView(deps.files);
|
|
22
|
+
return event === undefined ? journal : journal.filter((entry) => entry.event === event);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bundles the journal for the framework feedback loop. By default the project name and
|
|
35
|
+
* the approver's identity are redacted, since the bundle may leave the project.
|
|
36
|
+
*/
|
|
37
|
+
export async function exportFeedback(deps , redact ) {
|
|
38
|
+
const view = await loadView(deps.files);
|
|
39
|
+
const project = view.config?.config?.project ?? "";
|
|
40
|
+
const scrub = (value ) => {
|
|
41
|
+
if (typeof value === "string") return project === "" ? value : value.split(project).join("<project>");
|
|
42
|
+
if (Array.isArray(value)) return value.map(scrub);
|
|
43
|
+
if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrub(v)]));
|
|
44
|
+
return value;
|
|
45
|
+
};
|
|
46
|
+
const entries = redact ? view.journal.map((entry) => ({ ...(scrub(entry) ), by: "<redacted>" })) : view.journal;
|
|
47
|
+
return { format: "cruze-feedback/1", cruze: deps.bundle.version, exported_at: deps.clock.now().toISOString(), redacted: redact, entries };
|
|
48
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { evaluateApproval } from "../../domain/approvals/evaluate.js";
|
|
2
|
+
import { fingerprint } from "../../domain/approvals/fingerprint.js";
|
|
3
|
+
import { serializeApprovals, withApproval, } from "../../domain/approvals/records.js";
|
|
4
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
5
|
+
import { renderAgentsFile } from "../../domain/edits/agents_file.js";
|
|
6
|
+
import { applyBookkeeping } from "../../domain/land/bookkeeping.js";
|
|
7
|
+
import { planMerge } from "../../domain/land/merge_plan.js";
|
|
8
|
+
import { parseMarkdown } from "../../domain/markdown.js";
|
|
9
|
+
import { resolveChange } from "../../domain/project/artifact_ref.js";
|
|
10
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
11
|
+
import { readTasks } from "../../domain/project/plan_parts.js";
|
|
12
|
+
import { readProgress } from "../../domain/project/progress.js";
|
|
13
|
+
import { approvalFolder, approvalsPath, } from "../../domain/project/project_view.js";
|
|
14
|
+
import { changesOf, featureOf, } from "../../domain/project/work_items.js";
|
|
15
|
+
import { isVerified } from "../../domain/status/verification.js";
|
|
16
|
+
import { activeChange } from "../../domain/status/work_status.js";
|
|
17
|
+
import { validateProject } from "../../domain/validation/validate_project.js";
|
|
18
|
+
import { appendJournal, loadView, withFiles, } from "../project_context.js";
|
|
19
|
+
|
|
20
|
+
const AGENTS_FILE = "AGENTS.md";
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lands a finished change: merges its work into the living docs, re-stamps the approvals the
|
|
34
|
+
* merge would otherwise make stale, records the land, and archives the work once it is finished.
|
|
35
|
+
* Nothing is written unless the merged docs still validate.
|
|
36
|
+
*/
|
|
37
|
+
export async function landChange(deps , ref , options = {}) {
|
|
38
|
+
const view = await loadView(deps.files);
|
|
39
|
+
const change = ref !== undefined ? resolveChange(view, ref) : activeChange(view, await deps.repository.currentBranch());
|
|
40
|
+
if (change === undefined) throw new CruzeError("no-active-change", "no change is bound to this branch; name one to land");
|
|
41
|
+
const source = change.kind === "change" ? featureOf(view.items, change) : change;
|
|
42
|
+
if (source === undefined) throw new CruzeError("orphan-change", `${change.ref} has no feature.md`);
|
|
43
|
+
requireReady(view, change, source);
|
|
44
|
+
const verified = isVerified(view.journal, change);
|
|
45
|
+
if (!verified && (options.override ?? "").trim() === "") {
|
|
46
|
+
throw new CruzeError("not-verified", `${change.ref} has no accepted verification; run verify, or land with --override <reason>`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const restamp = [PATHS.architecture, source.path, ...(source === change ? [] : changesOf(view.items, source).map((c) => c.path))]
|
|
50
|
+
.filter((path, i, all) => all.indexOf(path) === i && view.snapshot.has(path) && evaluateApproval(view, path).state === "approved");
|
|
51
|
+
|
|
52
|
+
const plan = planMerge(view, change, source);
|
|
53
|
+
const commit = await deps.repository.headCommit();
|
|
54
|
+
const landed = `${deps.clock.today()}${commit === null ? "" : ` (${commit})`}`;
|
|
55
|
+
const books = applyBookkeeping(view, change, source, landed, plan.files);
|
|
56
|
+
const merged = withFiles(view, plan.files);
|
|
57
|
+
requireStillValid(view, merged);
|
|
58
|
+
|
|
59
|
+
const approvals = new Map ();
|
|
60
|
+
const by = (await deps.repository.userName()) ?? "unknown";
|
|
61
|
+
for (const path of restamp) {
|
|
62
|
+
const folder = approvalFolder(merged, path);
|
|
63
|
+
const { hash, upstream } = fingerprint(merged, path, parseMarkdown(merged.snapshot.get(path) ?? ""));
|
|
64
|
+
const file = approvals.get(folder) ?? merged.approvals.get(folder) ?? { version: 1, approvals: [], merged: {} };
|
|
65
|
+
approvals.set(folder, withApproval(file, { artifact: path, hash, upstream, approvedAt: deps.clock.now().toISOString(), by, basis: `land ${change.ref}` }));
|
|
66
|
+
}
|
|
67
|
+
const sourceApprovals = approvals.get(source.folder) ?? merged.approvals.get(source.folder) ?? { version: 1, approvals: [], merged: {} };
|
|
68
|
+
approvals.set(source.folder, { ...sourceApprovals, merged: plan.merged });
|
|
69
|
+
|
|
70
|
+
for (const [path, text] of plan.files) await deps.files.writeText(path, text);
|
|
71
|
+
for (const [folder, file] of approvals) await deps.files.writeText(approvalsPath(folder), serializeApprovals(file));
|
|
72
|
+
for (const path of restamp) {
|
|
73
|
+
const record = approvals.get(approvalFolder(merged, path))?.approvals.find((r) => r.artifact === path);
|
|
74
|
+
await appendJournal(deps, approvalFolder(merged, path), "approve", { artifact: path, hash: record?.hash, basis: `land ${change.ref}` });
|
|
75
|
+
}
|
|
76
|
+
if (!verified) await appendJournal(deps, source.folder, "override", { gate: "land", reason: options.override, item: change.ref });
|
|
77
|
+
await appendJournal(deps, source.folder, "land", { change: change.ref, merged: plan.mergedIds, built: plan.built, removed: plan.removed, release: books.release, commit });
|
|
78
|
+
if (books.archive !== undefined) await deps.files.move(books.archive.from, books.archive.to);
|
|
79
|
+
const agents = await deps.files.readText(AGENTS_FILE);
|
|
80
|
+
if (agents !== undefined) {
|
|
81
|
+
const rendered = renderAgentsFile(agents, merged);
|
|
82
|
+
if (rendered !== agents) await deps.files.writeText(AGENTS_FILE, rendered);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const report = { change: change.ref, merged: plan.mergedIds, built: plan.built, removed: plan.removed, restamped: restamp, finished: books.finished };
|
|
86
|
+
if (books.archive !== undefined) report.archivedTo = books.archive.to;
|
|
87
|
+
return report;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The change, its feature and the architecture are approved and current, valid, and every task is done. */
|
|
91
|
+
function requireReady(view , change , source ) {
|
|
92
|
+
const reasons = [];
|
|
93
|
+
for (const path of [change.path, source.path, PATHS.architecture].filter((p, i, all) => all.indexOf(p) === i && view.snapshot.has(p))) {
|
|
94
|
+
const status = evaluateApproval(view, path);
|
|
95
|
+
if (status.state !== "approved") reasons.push(`${path} is ${status.state}${status.changed.length > 0 ? ` (${status.changed.join(", ")})` : ""}`);
|
|
96
|
+
}
|
|
97
|
+
const errors = validateProject(view).filter((p) => p.severity === "error" && (p.path === change.path || p.path === source.path));
|
|
98
|
+
reasons.push(...errors.map((p) => `${p.path}:${p.line ?? ""} ${p.message}`));
|
|
99
|
+
const progress = readProgress(change.doc);
|
|
100
|
+
const open = readTasks(change.doc).tasks.filter((task) => (progress.tasks.get(task.number) ?? null) === null).map((task) => task.number);
|
|
101
|
+
if (open.length > 0) reasons.push(`tasks not done: ${open.join(", ")}`);
|
|
102
|
+
if (reasons.length > 0) throw new CruzeError("not-ready", `${change.ref} cannot land yet:\n ${reasons.join("\n ")}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The merge may not introduce errors into the living docs. */
|
|
106
|
+
function requireStillValid(before , after ) {
|
|
107
|
+
const key = (p ) => `${p.path}|${p.message}`;
|
|
108
|
+
const existing = new Set(validateProject(before).map(key));
|
|
109
|
+
const introduced = validateProject(after).filter((p) => p.severity === "error" && p.path.startsWith("docs/") && !existing.has(key(p)));
|
|
110
|
+
if (introduced.length > 0) {
|
|
111
|
+
throw new CruzeError("merge-invalid", `landing would break the living docs; nothing was written:\n ${introduced.map((p) => `${p.path}:${p.line ?? ""} ${p.message}`).join("\n ")}`);
|
|
112
|
+
}
|
|
113
|
+
}
|