@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,40 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
3
|
+
import { requireSlug } from "../../domain/project/new_items.js";
|
|
4
|
+
import { renderTemplate } from "../../domain/scaffold.js";
|
|
5
|
+
import { appendJournal, loadView, } from "../project_context.js";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export const PROJECT_DOC_KINDS = ["vision", "glossary", "architecture", "roadmap"];
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
/** Creates one of the project's living documents from its template, named for the project. */
|
|
17
|
+
export async function createProjectDoc(deps , kind ) {
|
|
18
|
+
const view = await loadView(deps.files);
|
|
19
|
+
const path = PATHS[kind];
|
|
20
|
+
if (view.snapshot.has(path)) throw new CruzeError("exists", `${path} already exists`);
|
|
21
|
+
const project = view.config?.config?.project ?? "";
|
|
22
|
+
if (project === "") throw new CruzeError("no-config", `${PATHS.config} is missing or has no project name; run cruze init first`);
|
|
23
|
+
const values = { project_name: project, id: "", title: "", date: deps.clock.today() };
|
|
24
|
+
await deps.files.writeText(path, renderTemplate(await deps.bundle.formatTemplate(`${kind}.md`), values));
|
|
25
|
+
await appendJournal(deps, ".cruze", "new", { kind, ref: path });
|
|
26
|
+
return { kind, path };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Creates a dated note in `.cruze/notes/`, such as a captured explore session or research evidence. */
|
|
30
|
+
export async function createNote(deps , slug , title ) {
|
|
31
|
+
requireSlug(slug);
|
|
32
|
+
if (title.trim() === "") throw new CruzeError("missing-title", "a title is required");
|
|
33
|
+
const view = await loadView(deps.files);
|
|
34
|
+
const date = deps.clock.today();
|
|
35
|
+
const base = `${PATHS.notesDir}/${date}-${slug}`;
|
|
36
|
+
let path = `${base}.md`;
|
|
37
|
+
for (let n = 2; view.snapshot.has(path); n++) path = `${base}-${n}.md`;
|
|
38
|
+
await deps.files.writeText(path, `# ${title}\n\n- Date: ${date}\n\n`);
|
|
39
|
+
return { kind: "note", path };
|
|
40
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { 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 { allocateId, changeRow, fillTemplate, requireSlug } from "../../domain/project/new_items.js";
|
|
6
|
+
import { renderTemplate } from "../../domain/scaffold.js";
|
|
7
|
+
import { appendJournal, loadView, } from "../project_context.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
/** Creates a feature, change or ADR from its template, with a fresh ID. */
|
|
28
|
+
export async function createWorkItem(deps , request ) {
|
|
29
|
+
const view = await loadView(deps.files);
|
|
30
|
+
const date = deps.clock.today();
|
|
31
|
+
const values = { id: "", title: request.title, date, project_name: "" };
|
|
32
|
+
if (request.title.trim() === "") throw new CruzeError("missing-title", "a title is required");
|
|
33
|
+
|
|
34
|
+
if (request.kind === "adr") {
|
|
35
|
+
requireSlug(request.slug);
|
|
36
|
+
const path = `${PATHS.adrDir}/${date}-${request.slug}.md`;
|
|
37
|
+
if (view.snapshot.has(path)) throw new CruzeError("exists", `${path} already exists`);
|
|
38
|
+
const text = renderTemplate(await deps.bundle.formatTemplate("adr.md"), values).replace(/^# .*$/m, `# ${request.title}`);
|
|
39
|
+
await deps.files.writeText(path, text);
|
|
40
|
+
await appendJournal(deps, ".cruze", "new", { kind: "adr", ref: path });
|
|
41
|
+
return { kind: "adr", ref: `ADR-${date}-${request.slug}`, path };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (request.kind === "change" && request.feature !== undefined) {
|
|
45
|
+
const { item: feature } = resolveArtifact(view, request.feature);
|
|
46
|
+
if (feature === undefined || feature.kind !== "feature" || feature.archived) throw new CruzeError("not-a-feature", `"${request.feature}" is not an active feature`);
|
|
47
|
+
const row = changeRow(view, feature, request.slug);
|
|
48
|
+
const folder = `${feature.folder}/changes/${row.change}`;
|
|
49
|
+
const template = renderTemplate(await deps.bundle.formatTemplate("change.md"), { ...values, id: row.change });
|
|
50
|
+
await deps.files.writeText(`${folder}/change.md`, fillTemplate(template, { delivers: row.delivers, builds: [...row.builds, ...row.removes] }));
|
|
51
|
+
await appendJournal(deps, feature.folder, "new", { kind: "change", ref: `${feature.ref}/${row.change}` });
|
|
52
|
+
return { kind: "change", ref: `${feature.ref}/${row.change}`, path: `${folder}/change.md` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const kind = request.kind === "feature" ? "feature" : "standalone";
|
|
56
|
+
const id = allocateId(view, date, request.slug);
|
|
57
|
+
const folder = `${kind === "feature" ? PATHS.featuresDir : PATHS.changesDir}/${id}`;
|
|
58
|
+
const roadmap = request.roadmap ?? (roadmapHasItem(view.snapshot.get(PATHS.roadmap), request.slug) ? request.slug : undefined);
|
|
59
|
+
const template = renderTemplate(await deps.bundle.formatTemplate(kind === "feature" ? "feature.md" : "standalone-change.md"), { ...values, id });
|
|
60
|
+
const path = `${folder}/${kind === "feature" ? "feature.md" : "change.md"}`;
|
|
61
|
+
await deps.files.writeText(path, fillTemplate(template, roadmap === undefined ? {} : { roadmap }));
|
|
62
|
+
const roadmapText = view.snapshot.get(PATHS.roadmap);
|
|
63
|
+
if (roadmap !== undefined && roadmapText !== undefined) {
|
|
64
|
+
await deps.files.writeText(PATHS.roadmap, upsertRow(roadmapText, "Status", [roadmap, id, "designing"]));
|
|
65
|
+
}
|
|
66
|
+
await appendJournal(deps, folder, "new", { kind, ref: id });
|
|
67
|
+
return { kind, ref: id, path };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether a roadmap phase lists this slug as an item. */
|
|
71
|
+
function roadmapHasItem(roadmap , slug ) {
|
|
72
|
+
if (roadmap === undefined) return false;
|
|
73
|
+
const phases = roadmap.split(/^## /m).find((section) => section.startsWith("Phases")) ?? "";
|
|
74
|
+
return phases.split("\n").some((line) => line.startsWith(`| ${slug} |`));
|
|
75
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { readProgress } from "../../domain/project/progress.js";
|
|
3
|
+
import { buildProjectView } from "../../domain/project/project_view.js";
|
|
4
|
+
import { nextSteps, } from "../../domain/status/next_step.js";
|
|
5
|
+
import { activeChange, buildGateReasons, projectStatus, touchedIds, } from "../../domain/status/work_status.js";
|
|
6
|
+
import { appendJournal, loadView, } from "../project_context.js";
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export async function showStatus(deps ) {
|
|
13
|
+
const branch = await deps.repository.currentBranch();
|
|
14
|
+
return { branch, ...projectStatus(await loadView(deps.files), branch) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The step to run next on this branch, and other work that could proceed. */
|
|
18
|
+
export async function suggestNext(deps ) {
|
|
19
|
+
const branch = await deps.repository.currentBranch();
|
|
20
|
+
return { branch, ...nextSteps(await loadView(deps.files), branch) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The build gate: the change bound to this branch, its feature and the architecture are
|
|
32
|
+
* all approved and current. An override passes the gate and is journaled with its reason.
|
|
33
|
+
*/
|
|
34
|
+
export async function checkBuildGate(deps , override ) {
|
|
35
|
+
const view = await loadView(deps.files);
|
|
36
|
+
const branch = await deps.repository.currentBranch();
|
|
37
|
+
const reasons = buildGateReasons(view, branch);
|
|
38
|
+
if (reasons.length === 0) return { gate: "build", passed: true, reasons, overridden: false };
|
|
39
|
+
if (override === undefined) return { gate: "build", passed: false, reasons, overridden: false };
|
|
40
|
+
if (override.trim() === "") throw new CruzeError("override-reason", "an override needs a reason");
|
|
41
|
+
const folder = activeChange(view, branch)?.folder ?? ".cruze";
|
|
42
|
+
await appendJournal(deps, folder, "override", { gate: "build", reason: override, blocked_by: reasons });
|
|
43
|
+
return { gate: "build", passed: true, reasons, overridden: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
/** In-flight work on other branches that touches the same elements as this branch's change. */
|
|
53
|
+
export async function findOverlaps(deps , ref ) {
|
|
54
|
+
const view = await loadView(deps.files);
|
|
55
|
+
const branch = await deps.repository.currentBranch();
|
|
56
|
+
const change = ref === undefined ? activeChange(view, branch) : view.items.find((item) => item.ref === ref);
|
|
57
|
+
if (change === undefined) throw new CruzeError("no-active-change", "no change is bound to this branch; name one with --change");
|
|
58
|
+
const mine = touchedIds(view, change);
|
|
59
|
+
const overlaps = [];
|
|
60
|
+
for (const other of await deps.repository.otherBranches()) {
|
|
61
|
+
const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat([...(await deps.repository.filesOnBranch(other, ".cruze"))])));
|
|
62
|
+
// Work in flight on a branch is the change bound to it.
|
|
63
|
+
const boundThere = theirs.items.filter((i) => {
|
|
64
|
+
const bound = readProgress(i.doc).branch;
|
|
65
|
+
return !i.archived && i.ref !== change.ref && bound !== undefined && (other === bound || other.endsWith(`/${bound}`));
|
|
66
|
+
});
|
|
67
|
+
for (const item of boundThere) {
|
|
68
|
+
const shared = [...touchedIds(theirs, item)].filter((id) => mine.has(id));
|
|
69
|
+
if (shared.length > 0) overlaps.push({ branch: other, item: item.ref, shared: shared.sort() });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { change: change.ref, overlaps };
|
|
73
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { readRows, removeRow, upsertRow } from "../../domain/edits/managed_tables.js";
|
|
3
|
+
import { updateProgress } from "../../domain/edits/progress_block.js";
|
|
4
|
+
import { resolveChange } from "../../domain/project/artifact_ref.js";
|
|
5
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
6
|
+
import { readTasks } from "../../domain/project/plan_parts.js";
|
|
7
|
+
import { requireSlug } from "../../domain/project/new_items.js";
|
|
8
|
+
import { activeChange } from "../../domain/status/work_status.js";
|
|
9
|
+
|
|
10
|
+
import { appendJournal, loadView, } from "../project_context.js";
|
|
11
|
+
|
|
12
|
+
/** Commands that write the CLI-owned progress records: task ticks, deviations and the feature map. */
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/** Marks a task done, recording the commit that completed it (HEAD by default). */
|
|
22
|
+
export async function completeTask(deps , task , options ) {
|
|
23
|
+
const change = await changeFor(deps, options.change);
|
|
24
|
+
requireTask(change, task);
|
|
25
|
+
const commit = options.commit ?? (await deps.repository.headCommit()) ?? "";
|
|
26
|
+
const text = updateProgress(change.doc.lines.join("\n"), (progress) => progress.tasks.set(task, commit));
|
|
27
|
+
await deps.files.writeText(change.path, text);
|
|
28
|
+
const ticks = [...text.matchAll(/^- \[([ x])\] T\d+/gm)];
|
|
29
|
+
return { change: change.ref, task, done: ticks.filter((m) => m[1] === "x").length, total: ticks.length };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reopens a done task whose design a rethink changed, so build does it again. */
|
|
33
|
+
export async function reopenTask(deps , task , reason , options ) {
|
|
34
|
+
const change = await changeFor(deps, options.change);
|
|
35
|
+
requireTask(change, task);
|
|
36
|
+
if (reason.trim() === "") throw new CruzeError("missing-reason", "say why the task reopens");
|
|
37
|
+
await deps.files.writeText(change.path, updateProgress(change.doc.lines.join("\n"), (progress) => progress.tasks.set(task, null)));
|
|
38
|
+
await appendJournal(deps, change.folder, "reopen", { change: change.ref, task, reason: reason.trim() });
|
|
39
|
+
return { change: change.ref, task };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Records a task-level choice the agent made on its own. Anything larger is a rethink. */
|
|
43
|
+
export async function recordDeviation(deps , task , text , options ) {
|
|
44
|
+
const change = await changeFor(deps, options.change);
|
|
45
|
+
requireTask(change, task);
|
|
46
|
+
if (text.trim() === "") throw new CruzeError("missing-text", "describe the deviation");
|
|
47
|
+
await deps.files.writeText(change.path, updateProgress(change.doc.lines.join("\n"), (progress) => progress.deviations.push(`${task}: ${text.trim()}`)));
|
|
48
|
+
await appendJournal(deps, change.folder, "deviation", { change: change.ref, task, text: text.trim() });
|
|
49
|
+
return { change: change.ref, task };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Adds a feature to the future list of the feature map, or replaces its entry. */
|
|
53
|
+
export async function addFutureFeature(deps , slug , summary , goals ) {
|
|
54
|
+
requireSlug(slug);
|
|
55
|
+
const vision = await requireVision(deps);
|
|
56
|
+
await deps.files.writeText(PATHS.vision, upsertRow(vision, "Future", [slug, summary, goals.join(", ")]));
|
|
57
|
+
await appendJournal(deps, ".cruze", "feature-map", { action: "add", feature: slug, summary, goals });
|
|
58
|
+
return { feature: slug };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function dropFutureFeature(deps , slug , reason ) {
|
|
62
|
+
const vision = await requireVision(deps);
|
|
63
|
+
const { text, removed } = removeRow(vision, "Future", slug);
|
|
64
|
+
if (removed === null) throw new CruzeError("not-found", `${slug} is not on the future list`);
|
|
65
|
+
if (reason.trim() === "") throw new CruzeError("missing-reason", "dropping a feature needs a reason");
|
|
66
|
+
await deps.files.writeText(PATHS.vision, text);
|
|
67
|
+
await appendJournal(deps, ".cruze", "feature-map", { action: "drop", feature: slug, reason });
|
|
68
|
+
return { feature: slug };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Clears the landed rows from the roadmap's Status table, when a release closes. */
|
|
72
|
+
export async function pruneRoadmap(deps ) {
|
|
73
|
+
const roadmap = await deps.files.readText(PATHS.roadmap);
|
|
74
|
+
if (roadmap === undefined) throw new CruzeError("not-found", `${PATHS.roadmap} does not exist yet`);
|
|
75
|
+
const landed = readRows(roadmap, "Status").filter((row) => row[2] === "landed").map((row) => row[0] ?? "");
|
|
76
|
+
let text = roadmap;
|
|
77
|
+
for (const item of landed) text = removeRow(text, "Status", item).text;
|
|
78
|
+
if (landed.length > 0) {
|
|
79
|
+
await deps.files.writeText(PATHS.roadmap, text);
|
|
80
|
+
await appendJournal(deps, ".cruze", "roadmap-prune", { items: landed });
|
|
81
|
+
}
|
|
82
|
+
return { removed: landed };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function changeFor(deps , ref ) {
|
|
86
|
+
const view = await loadView(deps.files);
|
|
87
|
+
if (ref !== undefined) return resolveChange(view, ref);
|
|
88
|
+
const change = activeChange(view, await deps.repository.currentBranch());
|
|
89
|
+
if (change === undefined) throw new CruzeError("no-active-change", "no change is bound to this branch; name one with --change");
|
|
90
|
+
return change;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireTask(change , task ) {
|
|
94
|
+
if (!readTasks(change.doc).tasks.some((t) => t.number === task)) throw new CruzeError("unknown-task", `${change.ref} has no task ${task}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function requireVision(deps ) {
|
|
98
|
+
const vision = await deps.files.readText(PATHS.vision);
|
|
99
|
+
if (vision === undefined) throw new CruzeError("not-found", `${PATHS.vision} does not exist yet`);
|
|
100
|
+
return vision;
|
|
101
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.js";
|
|
3
|
+
import { featureOf } from "../../domain/project/work_items.js";
|
|
4
|
+
|
|
5
|
+
import { loadView, } from "../project_context.js";
|
|
6
|
+
|
|
7
|
+
export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher"] ;
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Runs a Cruze role in a fresh agent context: the role's prompt, what it reviews, and nothing
|
|
30
|
+
* from the conversation that produced the work. Returns the role's report as written.
|
|
31
|
+
*/
|
|
32
|
+
export async function runReview(deps , request ) {
|
|
33
|
+
if (!(ROLES ).includes(request.role)) throw new CruzeError("unknown-role", `the roles are ${ROLES.join(", ")}`);
|
|
34
|
+
const role = request.role ;
|
|
35
|
+
const view = await loadView(deps.files);
|
|
36
|
+
const roleText = (await deps.files.readText(`.agents/skills/cruze-roles/${role}.md`)) ?? (await bundledRole(deps, role));
|
|
37
|
+
|
|
38
|
+
const context = [`Round: ${request.round ?? 1}`];
|
|
39
|
+
if (request.item !== undefined) {
|
|
40
|
+
const { path, item } = resolveArtifact(view, request.item);
|
|
41
|
+
context.push(`Under review: ${item?.ref ?? path} (\`${path}\`)`);
|
|
42
|
+
const feature = item === undefined ? undefined : featureOf(view.items, item);
|
|
43
|
+
if (feature !== undefined) context.push(`Its feature: \`${feature.path}\``);
|
|
44
|
+
if (role === "design-reviewer" && item !== undefined && item.kind !== "feature") context.push("This is a change's plan: review it with the plan rubric.");
|
|
45
|
+
} else {
|
|
46
|
+
context.push("Under review: the project documents the role lists for project scope");
|
|
47
|
+
}
|
|
48
|
+
if (request.base !== undefined) context.push(`The changes under review: run \`git diff ${request.base}\`.`);
|
|
49
|
+
if (request.blockers !== undefined) context.push(`Round-1 blockers to check:\n\n${request.blockers}`);
|
|
50
|
+
|
|
51
|
+
const prompt = [
|
|
52
|
+
roleText.trim(),
|
|
53
|
+
"## This run",
|
|
54
|
+
"You are running the role above in a fresh context, in the project's repository. Read every input it lists from the files, and run the commands it names. Edit no files. End with the report the role describes.",
|
|
55
|
+
context.map((line) => `- ${line}`).join("\n"),
|
|
56
|
+
].join("\n\n");
|
|
57
|
+
|
|
58
|
+
const command = view.config?.config?.review.command ?? [];
|
|
59
|
+
const run = await deps.runner.run(command, prompt);
|
|
60
|
+
if (run.exitCode !== 0) throw new CruzeError("review-failed", `${command.join(" ")} exited with ${run.exitCode}: ${run.error.trim() || run.output.trim()}`);
|
|
61
|
+
const report = { role, command, report: run.output.trim() };
|
|
62
|
+
if (request.item !== undefined) report.item = request.item;
|
|
63
|
+
return report;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function bundledRole(deps , role ) {
|
|
67
|
+
const roles = (await deps.bundle.skills()).find((skill) => skill.folder === "roles");
|
|
68
|
+
const file = roles?.files.find((f) => f.path === `${role}.md`);
|
|
69
|
+
if (file === undefined) throw new CruzeError("role-missing", `the ${role} role is not installed; run cruze install`);
|
|
70
|
+
return file.text;
|
|
71
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { hasErrors, validateProject } from "../../domain/validation/validate_project.js";
|
|
2
|
+
|
|
3
|
+
import { loadView } from "../project_context.js";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/** Checks every document in the project against the cruze-formats rules. */
|
|
14
|
+
export async function validate(deps ) {
|
|
15
|
+
const problems = validateProject(await loadView(deps.files));
|
|
16
|
+
return {
|
|
17
|
+
valid: !hasErrors(problems),
|
|
18
|
+
errors: problems.filter((p) => p.severity === "error").length,
|
|
19
|
+
warnings: problems.filter((p) => p.severity === "warning").length,
|
|
20
|
+
problems,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where agents look for skills. Skills are installed once into the shared
|
|
3
|
+
* Agent Skills directory; agents that don't read it get links in their own.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SHARED_SKILLS_DIR = ".agents/skills";
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export const AGENT_SKILL_HOMES = [
|
|
16
|
+
{ agent: "claude", skillsDir: ".claude/skills", markers: ["CLAUDE.md", ".claude"] },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function knownAgents() {
|
|
20
|
+
return AGENT_SKILL_HOMES.map((home) => home.agent);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function agentSkillHome(agent ) {
|
|
24
|
+
return AGENT_SKILL_HOMES.find((home) => home.agent === agent);
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parseMarkdown } from "../markdown.js";
|
|
2
|
+
import { approvalFolder, } from "../project/project_view.js";
|
|
3
|
+
import { currentUpstreamHash, fingerprint } from "./fingerprint.js";
|
|
4
|
+
import { latestApproval, } from "./records.js";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Computes an artifact's approval state from content, never from stored state:
|
|
20
|
+
* editing an upstream document is all it takes to make downstream work stale.
|
|
21
|
+
*/
|
|
22
|
+
export function evaluateApproval(view , artifact ) {
|
|
23
|
+
const record = latestApproval(view.approvals.get(approvalFolder(view, artifact)) ?? { version: 1, approvals: [], merged: {} }, artifact);
|
|
24
|
+
if (record === undefined) return { artifact, state: "unapproved", changed: [] };
|
|
25
|
+
const journaled = view.journal.some((e) => e.event === "approve" && e.artifact === artifact && e.hash === record.hash);
|
|
26
|
+
if (!journaled) return { artifact, state: "unapproved", changed: [], record, unjournaled: true };
|
|
27
|
+
|
|
28
|
+
const text = view.snapshot.get(artifact);
|
|
29
|
+
if (text === undefined) return { artifact, state: "unapproved", changed: [], record };
|
|
30
|
+
const { hash } = fingerprint(view, artifact, parseMarkdown(text));
|
|
31
|
+
if (hash !== record.hash) return { artifact, state: "edited", changed: [], record };
|
|
32
|
+
const changed = Object.entries(record.upstream)
|
|
33
|
+
.filter(([key, pinned]) => currentUpstreamHash(view, key) !== pinned)
|
|
34
|
+
.map(([key]) => key);
|
|
35
|
+
return { artifact, state: changed.length > 0 ? "upstream-changed" : "approved", changed, record };
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { headingElements, rowElements } from "../elements.js";
|
|
2
|
+
import { designHash, elementHash } from "../hashing.js";
|
|
3
|
+
import { findIds } from "../ids.js";
|
|
4
|
+
|
|
5
|
+
import { PATHS } from "../project/layout.js";
|
|
6
|
+
|
|
7
|
+
import { featureOf } from "../project/work_items.js";
|
|
8
|
+
|
|
9
|
+
/** What an approval pins: the document's design hash and the hash of everything upstream it relies on. */
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/** Documents a person can approve. Specs, the glossary and ADRs change only through approved work. */
|
|
16
|
+
export function isApprovable(view , path ) {
|
|
17
|
+
return [PATHS.vision, PATHS.architecture, PATHS.roadmap].includes(path ) || view.items.some((i) => i.path === path && !i.archived);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fingerprint(view , path , doc ) {
|
|
21
|
+
const upstream = {};
|
|
22
|
+
const ownIds = new Set([...headingElements(doc), ...rowElements(doc)].filter((e) => e.op !== "ADDED").map((e) => e.id));
|
|
23
|
+
const isLivingDoc = !view.items.some((item) => item.path === path);
|
|
24
|
+
const designText = doc.lines.filter((_, i) => !doc.inManaged[i]).join("\n");
|
|
25
|
+
|
|
26
|
+
for (const id of findIds(designText)) {
|
|
27
|
+
if (isLivingDoc && ownIds.has(id)) continue;
|
|
28
|
+
const living = view.living.elements.get(id);
|
|
29
|
+
if (living !== undefined) upstream[id] = elementHash(living.doc, living.element);
|
|
30
|
+
}
|
|
31
|
+
const item = view.items.find((i) => i.path === path);
|
|
32
|
+
if (item !== undefined) {
|
|
33
|
+
// Work always depends on the dependency rules, cited or not.
|
|
34
|
+
for (const [id, living] of view.living.elements) {
|
|
35
|
+
if (id.startsWith("RULE-")) upstream[id] = elementHash(living.doc, living.element);
|
|
36
|
+
}
|
|
37
|
+
const feature = item.kind === "change" ? featureOf(view.items, item) : undefined;
|
|
38
|
+
if (feature !== undefined) upstream[`doc:${feature.path}`] = designHash(feature.doc);
|
|
39
|
+
}
|
|
40
|
+
return { hash: designHash(doc), upstream };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The current hash of one upstream key, or undefined when it no longer exists. */
|
|
44
|
+
export function currentUpstreamHash(view , key ) {
|
|
45
|
+
if (key.startsWith("doc:")) {
|
|
46
|
+
const path = key.slice("doc:".length);
|
|
47
|
+
const item = view.items.find((i) => i.path === path);
|
|
48
|
+
return item === undefined ? undefined : designHash(item.doc);
|
|
49
|
+
}
|
|
50
|
+
const living = view.living.elements.get(key);
|
|
51
|
+
return living === undefined ? undefined : elementHash(living.doc, living.element);
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** What `approvals.json` holds. Only the CLI writes it. */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export function emptyApprovals() {
|
|
24
|
+
return { version: 1, approvals: [], merged: {} };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseApprovals(text ) {
|
|
28
|
+
if (text === undefined) return { file: emptyApprovals() };
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(text) ;
|
|
31
|
+
if (raw.version !== 1 || !Array.isArray(raw.approvals)) return { file: emptyApprovals(), problem: "approvals.json has an unknown format" };
|
|
32
|
+
return { file: { version: 1, approvals: raw.approvals, merged: raw.merged ?? {} } };
|
|
33
|
+
} catch {
|
|
34
|
+
return { file: emptyApprovals(), problem: "approvals.json is not valid JSON" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function serializeApprovals(file ) {
|
|
39
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The latest approval of an artifact, if any. */
|
|
43
|
+
export function latestApproval(file , artifact ) {
|
|
44
|
+
return file.approvals.filter((record) => record.artifact === artifact).at(-1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Replaces the artifact's approval: one current approval per artifact; history lives in the journal. */
|
|
48
|
+
export function withApproval(file , record ) {
|
|
49
|
+
return { ...file, approvals: [...file.approvals.filter((r) => r.artifact !== record.artifact), record] };
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
/** The `##` sections of architecture.md, in order, and the element kind each holds. */
|
|
4
|
+
export const ARCHITECTURE_SECTIONS = [
|
|
5
|
+
{ title: "Overview", kind: "VIEW" },
|
|
6
|
+
{ title: "Bounded contexts", kind: "CTX" },
|
|
7
|
+
{ title: "Domain model", kind: "ENT" },
|
|
8
|
+
{ title: "Use cases", kind: "UC" },
|
|
9
|
+
{ title: "Ports", kind: "PORT" },
|
|
10
|
+
{ title: "Adapters", kind: "ADP" },
|
|
11
|
+
{ title: "Flows", kind: "FLOW" },
|
|
12
|
+
{ title: "Modules", kind: "MOD" },
|
|
13
|
+
{ title: "Dependency rules", kind: "RULE" },
|
|
14
|
+
{ title: "Cross-cutting concerns", kind: "XC" },
|
|
15
|
+
{ title: "Decisions", kind: null },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const ARCHITECTURE_KINDS = ["VIEW", "CTX", "ENT", "UC", "PORT", "ADP", "FLOW", "MOD", "RULE", "XC"];
|
|
19
|
+
export const SPEC_KINDS = ["REQ", "SCN"];
|
|
20
|
+
|
|
21
|
+
export function sectionForKind(kind ) {
|
|
22
|
+
return ARCHITECTURE_SECTIONS.find((section) => section.kind === kind)?.title;
|
|
23
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
|
|
2
|
+
import { matchesAny } from "../glob.js";
|
|
3
|
+
import { importsOf, resolveImport } from "./imports.js";
|
|
4
|
+
import { countTypes } from "./type_count.js";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
/** Enforces the architecture's layer rules and the file budgets on source code. */
|
|
26
|
+
export function checkSource(input ) {
|
|
27
|
+
const { config, sources, modulePaths } = input;
|
|
28
|
+
const all = [...sources.keys()];
|
|
29
|
+
const targets = input.only ?? all;
|
|
30
|
+
const layerOf = (path ) => config.layers.find((layer) => matchesAny(path, layer.paths));
|
|
31
|
+
const findings = [];
|
|
32
|
+
|
|
33
|
+
for (const path of targets) {
|
|
34
|
+
const text = sources.get(path);
|
|
35
|
+
if (text === undefined) continue;
|
|
36
|
+
const layer = layerOf(path);
|
|
37
|
+
if (config.layers.length > 0 && layer === undefined) {
|
|
38
|
+
findings.push({ path, rule: "no-layer", severity: "warning", message: "the file is in no layer, so its imports are unchecked" });
|
|
39
|
+
}
|
|
40
|
+
if (layer !== undefined) {
|
|
41
|
+
for (const found of importsOf(path, text)) {
|
|
42
|
+
const target = resolveImport(found, all);
|
|
43
|
+
const targetLayer = target === undefined ? undefined : layerOf(target);
|
|
44
|
+
if (target === undefined || targetLayer === undefined || targetLayer.name === layer.name) continue;
|
|
45
|
+
if (!layer.mayImport.includes(targetLayer.name)) {
|
|
46
|
+
findings.push({ path, line: found.line + 1, rule: "layer", severity: "error", message: `${layer.name} may not import ${targetLayer.name} (${target})` });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const excepted = config.check.exceptions.some((exception) => exception.path === path);
|
|
51
|
+
const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
52
|
+
if (!excepted && lines > config.check.maxLines) {
|
|
53
|
+
findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines, over the budget of ${config.check.maxLines}; split it by responsibility` });
|
|
54
|
+
}
|
|
55
|
+
const types = countTypes(path, text);
|
|
56
|
+
if (!excepted && types !== null && types > config.check.maxTypes) {
|
|
57
|
+
findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
|
|
58
|
+
}
|
|
59
|
+
if (modulePaths.length > 0 && !modulePaths.some((module) => (module.endsWith("/") ? path.startsWith(module) : path === module))) {
|
|
60
|
+
findings.push({ path, rule: "outside-module-map", severity: "warning", message: "the file is outside every module in the architecture's module map" });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return findings;
|
|
64
|
+
}
|