@printpractical/cruze 0.0.1 → 0.0.2
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/README.md +38 -14
- package/dist/adapters/inbound/cli/cli_context.js +1 -0
- package/dist/adapters/inbound/cli/commands/code.js +2 -1
- package/dist/adapters/inbound/cli/commands/install.js +4 -0
- package/dist/adapters/inbound/cli/commands/realign.js +28 -0
- package/dist/adapters/inbound/cli/commands.js +2 -0
- package/dist/adapters/inbound/cli/parse_command.js +2 -0
- package/dist/adapters/inbound/cli/run_cli.js +9 -0
- package/dist/app/project_context.js +4 -2
- package/dist/app/use_cases/check_code.js +8 -3
- package/dist/app/use_cases/install_skills.js +8 -1
- package/dist/app/use_cases/journal_events.js +4 -3
- package/dist/app/use_cases/land_change.js +27 -6
- package/dist/app/use_cases/realign_done.js +45 -0
- package/dist/app/use_cases/realign_status.js +106 -0
- package/dist/app/use_cases/record_version.js +56 -0
- package/dist/app/use_cases/run_review.js +1 -1
- package/dist/app/use_cases/validate_project.js +10 -4
- package/dist/domain/check/check_source.js +23 -2
- package/dist/domain/config.js +10 -0
- package/dist/domain/journal.js +29 -5
- package/dist/domain/project/layout.js +1 -0
- package/dist/domain/status/next_step.js +19 -2
- package/dist/domain/validation/plan_rules.js +18 -1
- package/dist/domain/validation/project_rules.js +17 -1
- package/dist/domain/validation/validate_project.js +2 -1
- package/dist/domain/validation/work_item_rules.js +7 -0
- package/dist/domain/versions/ci_pin.js +40 -0
- package/dist/domain/versions/cruze_version.js +51 -0
- package/dist/domain/versions/realign_notes.js +72 -0
- package/dist/domain/versions/rule_versions.js +22 -0
- package/dist/domain/versions/scheduled_exceptions.js +41 -0
- package/package.json +1 -1
- package/skills/architect/feature.md +4 -2
- package/skills/behavioural-testing/SKILL.md +6 -0
- package/skills/build/SKILL.md +1 -1
- package/skills/envision/project.md +4 -2
- package/skills/formats/reference/config.md +6 -2
- package/skills/formats/reference/work-items.md +1 -1
- package/skills/hexagonal-design/SKILL.md +7 -1
- package/skills/hexagonal-design/languages/rust.md +7 -2
- package/skills/land/SKILL.md +1 -1
- package/skills/next/SKILL.md +1 -0
- package/skills/plan/tasks.md +7 -3
- package/skills/plan/test-plan.md +1 -1
- package/skills/realign/SKILL.md +35 -0
- package/skills/realign/notes/0.0.2.md +69 -0
- package/skills/realign/notes.md +29 -0
- package/skills/research/SKILL.md +2 -1
- package/skills/rethink/SKILL.md +3 -1
- package/skills/rethink/levels.md +1 -1
- package/skills/roles/SKILL.md +5 -2
- package/skills/roles/auditor.md +35 -0
- package/skills/roles/code-reviewer.md +4 -3
- package/skills/roles/design-reviewer.md +9 -6
- package/skills/roles/researcher.md +1 -0
- package/src/adapters/inbound/cli/cli_context.ts +1 -0
- package/src/adapters/inbound/cli/commands/code.ts +2 -1
- package/src/adapters/inbound/cli/commands/install.ts +4 -0
- package/src/adapters/inbound/cli/commands/realign.ts +28 -0
- package/src/adapters/inbound/cli/commands.ts +2 -0
- package/src/adapters/inbound/cli/parse_command.ts +2 -0
- package/src/adapters/inbound/cli/run_cli.ts +9 -0
- package/src/app/project_context.ts +4 -2
- package/src/app/use_cases/check_code.ts +8 -3
- package/src/app/use_cases/install_skills.ts +8 -1
- package/src/app/use_cases/journal_events.ts +4 -3
- package/src/app/use_cases/land_change.ts +27 -6
- package/src/app/use_cases/realign_done.ts +45 -0
- package/src/app/use_cases/realign_status.ts +106 -0
- package/src/app/use_cases/record_version.ts +56 -0
- package/src/app/use_cases/run_review.ts +1 -1
- package/src/app/use_cases/validate_project.ts +10 -4
- package/src/domain/check/check_source.ts +23 -2
- package/src/domain/config.ts +10 -0
- package/src/domain/journal.ts +29 -5
- package/src/domain/project/layout.ts +1 -0
- package/src/domain/status/next_step.ts +19 -2
- package/src/domain/validation/plan_rules.ts +18 -1
- package/src/domain/validation/project_rules.ts +17 -1
- package/src/domain/validation/validate_project.ts +2 -1
- package/src/domain/validation/work_item_rules.ts +7 -0
- package/src/domain/versions/ci_pin.ts +40 -0
- package/src/domain/versions/cruze_version.ts +51 -0
- package/src/domain/versions/realign_notes.ts +72 -0
- package/src/domain/versions/rule_versions.ts +22 -0
- package/src/domain/versions/scheduled_exceptions.ts +41 -0
- package/templates/init/config.yaml +4 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
3
|
+
import { repinCruze } from "../../domain/versions/ci_pin.js";
|
|
4
|
+
import { compareVersions, versionSkew, withRecordedVersion } from "../../domain/versions/cruze_version.js";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export const CI_WORKFLOW = ".github/workflows/ci.yml";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** The version the project's config records, read without requiring the rest of the config to be valid. */
|
|
18
|
+
export async function recordedVersion(files ) {
|
|
19
|
+
const text = await files.readText(PATHS.config);
|
|
20
|
+
return text === undefined ? undefined : versionIn(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What to tell the user when a Cruze project's skills don't match this CLI; nothing outside a Cruze project. */
|
|
24
|
+
export async function versionNotice(files , version ) {
|
|
25
|
+
const text = await files.readText(PATHS.config);
|
|
26
|
+
return text === undefined ? undefined : versionSkew(version, versionIn(text))?.message;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function versionIn(configText ) {
|
|
30
|
+
return /^cruze:\s*["']?([^"'\s#]+)/m.exec(configText)?.[1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Refuses to install older skills over a project that already moved to a newer Cruze. */
|
|
34
|
+
export async function refuseDowngrade(files , version ) {
|
|
35
|
+
const recorded = await recordedVersion(files);
|
|
36
|
+
if (recorded !== undefined && compareVersions(recorded, version) > 0) {
|
|
37
|
+
throw new CruzeError(
|
|
38
|
+
"older-cli",
|
|
39
|
+
`this project uses Cruze ${recorded}, and this CLI is ${version}; upgrade the CLI instead. To go back to ${version} on purpose, set cruze: in ${PATHS.config} first`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Records the installed version in the config, and moves the CI workflow's Cruze to it. */
|
|
45
|
+
export async function recordVersion(files , version ) {
|
|
46
|
+
const from = await recordedVersion(files);
|
|
47
|
+
const config = await files.readText(PATHS.config);
|
|
48
|
+
if (config !== undefined) {
|
|
49
|
+
const updated = withRecordedVersion(config, version);
|
|
50
|
+
if (updated !== config) await files.writeText(PATHS.config, updated);
|
|
51
|
+
}
|
|
52
|
+
const ci = await files.readText(CI_WORKFLOW);
|
|
53
|
+
const repin = ci === undefined ? { text: "", repinned: [], unrecognised: [] } : repinCruze(ci, version);
|
|
54
|
+
if (ci !== undefined && repin.text !== ci) await files.writeText(CI_WORKFLOW, repin.text);
|
|
55
|
+
return { ...(from === undefined ? {} : { from }), to: version, ci: { repinned: repin.repinned, unrecognised: repin.unrecognised } };
|
|
56
|
+
}
|
|
@@ -4,7 +4,7 @@ import { featureOf } from "../../domain/project/work_items.js";
|
|
|
4
4
|
|
|
5
5
|
import { loadView, } from "../project_context.js";
|
|
6
6
|
|
|
7
|
-
export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher"] ;
|
|
7
|
+
export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher", "auditor"] ;
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { hasErrors, validateProject } from "../../domain/validation/validate_project.js";
|
|
2
|
-
|
|
2
|
+
import { warning, } from "../../domain/validation/problem.js";
|
|
3
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
4
|
+
import { versionSkew } from "../../domain/versions/cruze_version.js";
|
|
5
|
+
|
|
3
6
|
import { loadView } from "../project_context.js";
|
|
4
7
|
|
|
5
8
|
|
|
@@ -10,9 +13,12 @@ import { loadView } from "../project_context.js";
|
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
/** Checks every document in the project against the cruze-formats rules. */
|
|
14
|
-
export async function validate(deps
|
|
15
|
-
const
|
|
16
|
+
/** Checks every document in the project against the cruze-formats rules, and that its skills match this CLI. */
|
|
17
|
+
export async function validate(deps ) {
|
|
18
|
+
const view = await loadView(deps.files);
|
|
19
|
+
const config = view.config?.config;
|
|
20
|
+
const skew = config === null || config === undefined ? undefined : versionSkew(deps.bundle.version, config.cruze);
|
|
21
|
+
const problems = [...validateProject(view), ...(skew === undefined ? [] : [warning(PATHS.config, undefined, "cruze-version", skew.message)])];
|
|
16
22
|
return {
|
|
17
23
|
valid: !hasErrors(problems),
|
|
18
24
|
errors: problems.filter((p) => p.severity === "error").length,
|
|
@@ -6,10 +6,12 @@ import { countTypes } from "./type_count.js";
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
|
|
14
|
+
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
|
|
@@ -56,9 +58,28 @@ export function checkSource(input ) {
|
|
|
56
58
|
if (!excepted && types !== null && types > config.check.maxTypes) {
|
|
57
59
|
findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
|
|
58
60
|
}
|
|
59
|
-
if (
|
|
61
|
+
if (!excepted && isRustTestFileInSource(path)) {
|
|
62
|
+
findings.push({ path, rule: "test-placement", severity: "warning", message: "Rust tests that leave their inline module go in the crate's tests/ directory, not under src/" });
|
|
63
|
+
}
|
|
64
|
+
if (modulePaths.length > 0 && !insideModuleMap(path, modulePaths)) {
|
|
60
65
|
findings.push({ path, rule: "outside-module-map", severity: "warning", message: "the file is outside every module in the architecture's module map" });
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
return findings;
|
|
64
69
|
}
|
|
70
|
+
|
|
71
|
+
/** Whether a module's Path covers the file: a directory path ending in `/` covers everything under it, any other names one file. */
|
|
72
|
+
export function insideModuleMap(path , modulePaths ) {
|
|
73
|
+
return modulePaths.some((module) => (module.endsWith("/") ? path.startsWith(module) : path === module));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A Rust file of tests split out of a module: `tests.rs`, `*_tests.rs`, or anything under a `tests/` folder inside `src/`. */
|
|
77
|
+
function isRustTestFileInSource(path ) {
|
|
78
|
+
if (!path.endsWith(".rs")) return false;
|
|
79
|
+
const segments = path.split("/");
|
|
80
|
+
const inSource = segments.lastIndexOf("src");
|
|
81
|
+
if (inSource === -1) return false;
|
|
82
|
+
const below = segments.slice(inSource + 1);
|
|
83
|
+
const file = below.at(-1) ?? "";
|
|
84
|
+
return file === "tests.rs" || file.endsWith("_tests.rs") || below.slice(0, -1).includes("tests");
|
|
85
|
+
}
|
package/dist/domain/config.js
CHANGED
|
@@ -3,6 +3,10 @@ import { parse as parseYaml } from "yaml";
|
|
|
3
3
|
/** `.cruze/config.yaml`, as described in the cruze-formats skill. */
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
6
10
|
|
|
7
11
|
|
|
8
12
|
|
|
@@ -43,6 +47,10 @@ export function parseConfig(text ) {
|
|
|
43
47
|
if (root === null) return { config: null, problems: ["the file must be a YAML mapping"] };
|
|
44
48
|
|
|
45
49
|
if (root["version"] !== 1) problems.push("version must be 1");
|
|
50
|
+
const cruze = root["cruze"];
|
|
51
|
+
if (cruze !== undefined && (typeof cruze !== "string" || cruze.trim() === "")) problems.push("cruze must be a version string, such as \"0.0.2\"");
|
|
52
|
+
const standards = root["standards"];
|
|
53
|
+
if (standards !== undefined && (typeof standards !== "string" || standards.trim() === "")) problems.push("standards must be a version string, such as \"0.0.2\"");
|
|
46
54
|
if (typeof root["project"] !== "string" || root["project"] === "") problems.push("project must be a non-empty string");
|
|
47
55
|
if (root["tracker"] !== "markdown") problems.push("tracker must be markdown");
|
|
48
56
|
const source = stringList(root["source"], "source", problems);
|
|
@@ -92,6 +100,8 @@ export function parseConfig(text ) {
|
|
|
92
100
|
|
|
93
101
|
const config = {
|
|
94
102
|
version: 1,
|
|
103
|
+
...(typeof cruze === "string" && cruze.trim() !== "" ? { cruze } : {}),
|
|
104
|
+
...(typeof standards === "string" && standards.trim() !== "" ? { standards } : {}),
|
|
95
105
|
project: typeof root["project"] === "string" ? root["project"] : "",
|
|
96
106
|
tracker: "markdown",
|
|
97
107
|
source,
|
package/dist/domain/journal.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
/** Events other tools and skills record, with the fields each requires. */
|
|
11
11
|
export const RECORDED_EVENTS = {
|
|
12
|
-
rethink: ["level", "kind", "summary", "wrong", "
|
|
12
|
+
rethink: ["level", "kind", "summary", "wrong", "found_by"],
|
|
13
13
|
disposition: ["finding", "disposition", "reason", "review"],
|
|
14
14
|
review: ["review", "round", "blockers", "concerns"],
|
|
15
15
|
override: ["gate", "reason"],
|
|
@@ -17,14 +17,28 @@ export const RECORDED_EVENTS = {
|
|
|
17
17
|
verification: ["result", "summary"],
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/** Fields an event may carry beyond its required ones, checked when present. A defect rethink must say which step missed it. */
|
|
21
|
+
const OPTIONAL_FIELDS = {
|
|
22
|
+
rethink: ["missed_by"],
|
|
23
|
+
review: ["nits"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The workflow steps and checks that find or miss a problem, so a retro can count them. */
|
|
27
|
+
export const STEPS = ["envision", "architect", "research", "design-review", "walkthrough", "plan", "plan-review", "build", "code-review", "verify", "land", "realign", "validate", "check", "user"];
|
|
28
|
+
|
|
20
29
|
export const FIELD_VALUES = {
|
|
21
30
|
level: ["task", "change", "feature", "architecture", "vision"],
|
|
22
31
|
kind: ["defect", "discovery"],
|
|
23
32
|
disposition: ["fixed", "waived", "deferred", "rejected"],
|
|
24
|
-
review: ["design", "plan", "code"],
|
|
33
|
+
review: ["design", "plan", "code", "realign"],
|
|
25
34
|
result: ["accepted", "sent-back"],
|
|
35
|
+
found_by: STEPS,
|
|
36
|
+
missed_by: STEPS,
|
|
26
37
|
};
|
|
27
38
|
|
|
39
|
+
/** Fields recorded as numbers rather than text. */
|
|
40
|
+
const COUNT_FIELDS = ["round", "blockers", "concerns", "nits"];
|
|
41
|
+
|
|
28
42
|
export function journalPath(folder ) {
|
|
29
43
|
return `${folder}/journal.jsonl`;
|
|
30
44
|
}
|
|
@@ -53,10 +67,20 @@ export function serializeEntry(entry ) {
|
|
|
53
67
|
export function recordedEventProblems(event , fields ) {
|
|
54
68
|
const required = RECORDED_EVENTS[event];
|
|
55
69
|
if (required === undefined) return [`unknown event "${event}"; recordable events: ${Object.keys(RECORDED_EVENTS).join(", ")}`];
|
|
56
|
-
const
|
|
57
|
-
|
|
70
|
+
const needed = event === "rethink" && fields["kind"] === "defect" ? [...required, "missed_by"] : required;
|
|
71
|
+
const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${field === "missed_by" ? " for a defect" : ""}`);
|
|
72
|
+
const known = [...required, ...(OPTIONAL_FIELDS[event] ?? [])];
|
|
73
|
+
for (const field of known) {
|
|
58
74
|
const value = fields[field];
|
|
59
|
-
if (value
|
|
75
|
+
if (value === undefined) continue;
|
|
76
|
+
const allowed = FIELD_VALUES[field];
|
|
77
|
+
if (allowed !== undefined && !allowed.includes(value)) problems.push(`${field} must be one of ${allowed.join(", ")}`);
|
|
78
|
+
if (COUNT_FIELDS.includes(field) && !/^\d+$/.test(value)) problems.push(`${field} must be a whole number`);
|
|
60
79
|
}
|
|
61
80
|
return problems;
|
|
62
81
|
}
|
|
82
|
+
|
|
83
|
+
/** The fields as the journal stores them: counts become numbers. */
|
|
84
|
+
export function recordedFields(fields ) {
|
|
85
|
+
return Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, COUNT_FIELDS.includes(key) ? Number(value) : value]));
|
|
86
|
+
}
|
|
@@ -5,12 +5,14 @@ import { readProgress } from "../project/progress.js";
|
|
|
5
5
|
|
|
6
6
|
import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.js";
|
|
7
7
|
import { changesOf, featureOf, } from "../project/work_items.js";
|
|
8
|
+
import { compareVersions } from "../versions/cruze_version.js";
|
|
9
|
+
import { FIRST_RELEASE } from "../versions/rule_versions.js";
|
|
8
10
|
import { isVerified } from "./verification.js";
|
|
9
11
|
import { activeChange } from "./work_status.js";
|
|
10
12
|
|
|
11
13
|
/** The Cruze step to run next, computed from the project's state and the current branch. */
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
|
|
@@ -31,12 +33,27 @@ export function nextSteps(view , branch )
|
|
|
31
33
|
const active = activeChange(view, branch);
|
|
32
34
|
const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
|
|
33
35
|
const roadmap = roadmapStep(view);
|
|
34
|
-
const
|
|
36
|
+
const realign = realignStep(view);
|
|
37
|
+
const candidates = [
|
|
38
|
+
...(active === undefined ? [] : [changeStep(view, active, branch)]),
|
|
39
|
+
...inFlight,
|
|
40
|
+
...(roadmap === null ? [] : [roadmap]),
|
|
41
|
+
...(realign === null ? [] : [realign]),
|
|
42
|
+
];
|
|
35
43
|
const unique = candidates.filter((c, i) => candidates.findIndex((o) => o.step === c.step && o.target === c.target) === i);
|
|
36
44
|
const [next, ...also] = unique;
|
|
37
45
|
return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
/** Realign comes when nothing else is due: the installed skills expect more than the code meets. */
|
|
49
|
+
function realignStep(view ) {
|
|
50
|
+
const config = view.config?.config;
|
|
51
|
+
if (config?.cruze === undefined) return null;
|
|
52
|
+
const standards = config.standards ?? FIRST_RELEASE;
|
|
53
|
+
if (compareVersions(standards, config.cruze) >= 0) return null;
|
|
54
|
+
return { step: "realign", target: config.cruze, reason: `the code meets Cruze ${standards}'s expectations, and the installed skills are ${config.cruze}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
/** The project documents come first: nothing else can proceed without them. */
|
|
41
58
|
function projectStep(view ) {
|
|
42
59
|
const docs = [
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { insideModuleMap } from "../check/check_source.js";
|
|
1
2
|
import { factsOf, isUnbuilt } from "../elements.js";
|
|
3
|
+
import { matchesAny } from "../glob.js";
|
|
2
4
|
import { findIds, kindOf } from "../ids.js";
|
|
3
5
|
|
|
4
6
|
|
|
5
|
-
import { isPlanned, readScope, readTasks, readTestPlan,
|
|
7
|
+
import { isPlanned, readScope, readTasks, readTestPlan, } from "../project/plan_parts.js";
|
|
6
8
|
|
|
7
9
|
import { error, warning, } from "./problem.js";
|
|
8
10
|
|
|
@@ -37,6 +39,7 @@ export function changePlanProblems(
|
|
|
37
39
|
problems.push(error(path, task.line, "unknown-id", `${task.number} proves ${id}, which is not defined`));
|
|
38
40
|
}
|
|
39
41
|
}
|
|
42
|
+
problems.push(...outsideModuleMap(view, path, tasks, delta));
|
|
40
43
|
for (const id of [...scope.delivers, ...scope.builds]) {
|
|
41
44
|
if (kindOf(id) === "MOD") {
|
|
42
45
|
const modulePaths = modulePathsOf(view, delta.doc, delta.delta, id);
|
|
@@ -103,3 +106,17 @@ function factsOfScoped(view , doc , delta , id
|
|
|
103
106
|
function modulePathsOf(view , doc , delta , id ) {
|
|
104
107
|
return (factsOfScoped(view, doc, delta, id).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")).filter((p) => p !== "");
|
|
105
108
|
}
|
|
109
|
+
|
|
110
|
+
/** Task paths that `cruze check` would report as outside every module, caught while the plan can still fix the module map. */
|
|
111
|
+
function outsideModuleMap(view , path , tasks , delta ) {
|
|
112
|
+
const source = view.config?.config?.source ?? [];
|
|
113
|
+
const removed = new Set(delta.delta.entries.filter((e) => e.element.op === "REMOVED").map((e) => e.element.id));
|
|
114
|
+
const modules = new Set([...view.living.elements.keys(), ...delta.delta.entries.map((e) => e.element.id)].filter((id) => kindOf(id) === "MOD" && !removed.has(id)));
|
|
115
|
+
const modulePaths = [...modules].flatMap((id) => modulePathsOf(view, delta.doc, delta.delta, id));
|
|
116
|
+
if (modulePaths.length === 0) return [];
|
|
117
|
+
return tasks.flatMap((task) =>
|
|
118
|
+
task.paths
|
|
119
|
+
.filter((file) => matchesAny(file, source) && !insideModuleMap(file, modulePaths))
|
|
120
|
+
.map((file) => warning(path, task.line, "outside-module-map", `${task.number} writes ${file}, which no module's Path covers; add it to a module's Path or move the file`)),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -7,7 +7,7 @@ import { readRoadmapItems } from "../project/roadmap.js";
|
|
|
7
7
|
|
|
8
8
|
import { error, warning, } from "./problem.js";
|
|
9
9
|
|
|
10
|
-
/** Rules that span the project: leftover template guides, the roadmap and the
|
|
10
|
+
/** Rules that span the project: leftover template guides, the roadmap, the config and the README. */
|
|
11
11
|
|
|
12
12
|
const GUIDE = /(?<!`)<(?!!--|\/|https?:|br\b)[A-Za-z][^<>\n]*>(?!`)/;
|
|
13
13
|
const PLACEHOLDER = /\{\{[a-z_]+\}\}/;
|
|
@@ -97,3 +97,19 @@ export function configProblems(view ) {
|
|
|
97
97
|
function modulePaths(doc , element ) {
|
|
98
98
|
return (factsOf(doc, element).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? ""));
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Once the vision is approved, the README says what the project is under its title.
|
|
103
|
+
* Images, badges and HTML lines don't count as that overview.
|
|
104
|
+
*/
|
|
105
|
+
export function readmeProblems(view ) {
|
|
106
|
+
const text = view.snapshot.get(PATHS.readme);
|
|
107
|
+
const visionApproved = [...view.approvals.values()].some((file) => file.approvals.some((record) => record.artifact === PATHS.vision));
|
|
108
|
+
if (text === undefined || !visionApproved) return [];
|
|
109
|
+
const doc = parseMarkdown(text);
|
|
110
|
+
const title = doc.lines.findIndex((line) => /^# /.test(line));
|
|
111
|
+
const next = doc.lines.findIndex((line, i) => i > title && /^#{1,6} /.test(line));
|
|
112
|
+
const intro = doc.lines.slice(title + 1, next === -1 ? undefined : next);
|
|
113
|
+
const overview = intro.some((line, i) => !doc.inFence[title + 1 + i] && line.trim() !== "" && !/^\s*(!\[|\[!\[|<)/.test(line));
|
|
114
|
+
return overview ? [] : [warning(PATHS.readme, Math.max(title, 0), "readme-overview", "add a paragraph under the title saying what the project is, who it is for and why, from docs/vision.md")];
|
|
115
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import { livingProblems } from "./living_rules.js";
|
|
3
3
|
import { error, sortProblems, } from "./problem.js";
|
|
4
|
-
import { configProblems, roadmapProblems, templateLeftovers } from "./project_rules.js";
|
|
4
|
+
import { configProblems, readmeProblems, roadmapProblems, templateLeftovers } from "./project_rules.js";
|
|
5
5
|
import { workItemProblems } from "./work_item_rules.js";
|
|
6
6
|
|
|
7
7
|
/** Every rule in the cruze-formats skill, applied to the whole project. */
|
|
@@ -16,6 +16,7 @@ export function validateProject(view ) {
|
|
|
16
16
|
...roadmapProblems(view),
|
|
17
17
|
...workItemProblems(view),
|
|
18
18
|
...templateLeftovers(view),
|
|
19
|
+
...readmeProblems(view),
|
|
19
20
|
...approvalProblems,
|
|
20
21
|
...journalProblems,
|
|
21
22
|
]);
|
|
@@ -56,6 +56,9 @@ function itemProblems(view , item ) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** A feature's Changes table covers its deltas, and each change folder agrees with its row. */
|
|
59
|
+
/** More changes than this, and one design review can't hold the whole feature: contradictions slip through to plan. */
|
|
60
|
+
const MAX_CHANGES = 6;
|
|
61
|
+
|
|
59
62
|
function featureScopeProblems(view , feature , delta ) {
|
|
60
63
|
const rows = readChangesTable(feature.doc);
|
|
61
64
|
if (rows.length === 0) return [warning(feature.path, undefined, "not-designed", "the feature has no changes yet; architect designs and splits it")];
|
|
@@ -64,6 +67,10 @@ function featureScopeProblems(view , feature , delta
|
|
|
64
67
|
[...readProgress(feature.doc).changes].filter(([, state]) => state === "landed").map(([change]) => change),
|
|
65
68
|
);
|
|
66
69
|
const names = rows.map((row) => row.change);
|
|
70
|
+
const extra = rows[MAX_CHANGES];
|
|
71
|
+
if (extra !== undefined) {
|
|
72
|
+
problems.push(warning(feature.path, extra.line, "feature-size", `the feature has ${rows.length} changes, over ${MAX_CHANGES}; split it into features a single design review can hold`));
|
|
73
|
+
}
|
|
67
74
|
for (const row of rows) {
|
|
68
75
|
if (!/^\d\d-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(row.change)) problems.push(error(feature.path, row.line, "change-name", `change "${row.change}" must be named NN-slug`));
|
|
69
76
|
if (names.indexOf(row.change) !== names.lastIndexOf(row.change)) problems.push(error(feature.path, row.line, "change-name", `change ${row.change} is listed twice`));
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** The Cruze package CI runs through `npx --package=<spec>`, kept at the version the project installed. */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
const FORMS = [
|
|
12
|
+
// The npm package: @printpractical/cruze@0.0.1
|
|
13
|
+
(spec, version) => (/^@printpractical\/cruze@[^/\s]+$/.test(spec) ? `@printpractical/cruze@${version}` : null),
|
|
14
|
+
// The release tarball: .../releases/download/v0.0.1/printpractical-cruze-0.0.1.tgz
|
|
15
|
+
(spec, version) => {
|
|
16
|
+
const m = /^(.*\/releases\/download\/)v[^/]+\/printpractical-cruze-[^/]+\.tgz$/.exec(spec);
|
|
17
|
+
return m === null ? null : `${m[1]}v${version}/printpractical-cruze-${version}.tgz`;
|
|
18
|
+
},
|
|
19
|
+
// The git repository at a release tag: github:PrintPractical/cruze#v0.0.1
|
|
20
|
+
(spec, version) => {
|
|
21
|
+
const m = /^(.*cruze(?:\.git)?)#v\d[^\s]*$/.exec(spec);
|
|
22
|
+
return m === null ? null : `${m[1]}#v${version}`;
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Moves every `--package=<spec>` that runs Cruze in a CI workflow to the version. */
|
|
27
|
+
export function repinCruze(ciText , version ) {
|
|
28
|
+
const repinned = [];
|
|
29
|
+
const unrecognised = [];
|
|
30
|
+
const text = ciText.replace(/--package=(\S+)(?=\s+cruze\b)/g, (whole, spec ) => {
|
|
31
|
+
const moved = FORMS.map((form) => form(spec, version)).find((result) => result !== null);
|
|
32
|
+
if (moved === undefined || moved === null) {
|
|
33
|
+
if (!unrecognised.includes(spec)) unrecognised.push(spec);
|
|
34
|
+
return whole;
|
|
35
|
+
}
|
|
36
|
+
if (!repinned.includes(moved)) repinned.push(moved);
|
|
37
|
+
return `--package=${moved}`;
|
|
38
|
+
});
|
|
39
|
+
return { text, repinned, unrecognised };
|
|
40
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Which Cruze a project runs: recorded in `.cruze/config.yaml` by `cruze install`, compared with the CLI. */
|
|
2
|
+
|
|
3
|
+
/** Orders two versions such as `0.0.1` and `0.1.0` by their numeric parts; a pre-release tag is ignored. */
|
|
4
|
+
export function compareVersions(a , b ) {
|
|
5
|
+
const parts = (v ) => (v.split("-")[0] ?? "").split(".").map((p) => Number.parseInt(p, 10) || 0);
|
|
6
|
+
const [x, y] = [parts(a), parts(b)];
|
|
7
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
8
|
+
const diff = (x[i] ?? 0) - (y[i] ?? 0);
|
|
9
|
+
if (diff !== 0) return diff;
|
|
10
|
+
}
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/** How the project's recorded Cruze differs from the CLI running now, or undefined when they match. */
|
|
20
|
+
export function versionSkew(cli , recorded ) {
|
|
21
|
+
if (recorded === undefined) {
|
|
22
|
+
return { kind: "unrecorded", message: `the project doesn't record which Cruze its skills came from; run \`cruze install\` to install the skills of this CLI (${cli}) and record it` };
|
|
23
|
+
}
|
|
24
|
+
const order = compareVersions(recorded, cli);
|
|
25
|
+
if (order < 0) {
|
|
26
|
+
return { kind: "skills-older", recorded, message: `the project's skills are from Cruze ${recorded}, older than this CLI (${cli}); run \`cruze install\` to update them and the CI pin` };
|
|
27
|
+
}
|
|
28
|
+
if (order > 0) {
|
|
29
|
+
return { kind: "cli-older", recorded, message: `the project uses Cruze ${recorded}, newer than this CLI (${cli}); upgrade the CLI as the README's "Upgrading" section says` };
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The config text with `cruze:` set to the version. The line is replaced in place, or added
|
|
36
|
+
* after `version:`, so the file's comments and layout survive.
|
|
37
|
+
*/
|
|
38
|
+
export function withRecordedVersion(configText , version ) {
|
|
39
|
+
return withVersionLine(configText, "cruze", version);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The config text with a top-level version key, such as `cruze:` or `standards:`, set in place. */
|
|
43
|
+
export function withVersionLine(configText , key , version ) {
|
|
44
|
+
const line = `${key}: ${JSON.stringify(version)}`;
|
|
45
|
+
const existing = new RegExp(`^${key}:.*$`, "m");
|
|
46
|
+
if (existing.test(configText)) return configText.replace(existing, line);
|
|
47
|
+
const anchor = key === "standards" ? /^cruze:.*$/m.exec(configText) ?? /^version:.*$/m.exec(configText) : /^version:.*$/m.exec(configText);
|
|
48
|
+
if (anchor === null) return `${line}\n${configText}`;
|
|
49
|
+
const end = anchor.index + anchor[0].length;
|
|
50
|
+
return `${configText.slice(0, end)}\n${line}${configText.slice(end)}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { compareVersions } from "./cruze_version.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A realign note: one change in what Cruze expects of a project, shipped in
|
|
5
|
+
* `skills/realign/notes/<version>.md` so `realign` can bring existing code up to it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
const APPLIES = { "existing code": "existing", "new work only": "new" };
|
|
28
|
+
|
|
29
|
+
/** Parses one version's notes file. Each note is a `### <id>: <title>` heading with facts under it. */
|
|
30
|
+
export function parseRealignNotes(version , text ) {
|
|
31
|
+
const notes = [];
|
|
32
|
+
const problems = [];
|
|
33
|
+
const sections = text.split(/^### /m).slice(1);
|
|
34
|
+
for (const section of sections) {
|
|
35
|
+
const [heading = "", ...body] = section.split("\n");
|
|
36
|
+
const match = /^([a-z0-9]+(?:-[a-z0-9]+)*): (.+)$/.exec(heading.trim());
|
|
37
|
+
if (match === null) {
|
|
38
|
+
problems.push(`${version}: "### ${heading.trim()}" must read "### <id>: <title>"`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const [, id = "", title = ""] = match;
|
|
42
|
+
const fact = (key ) => body.map((line) => new RegExp(`^- ${key}: (.+)$`).exec(line)?.[1]?.trim()).find((v) => v !== undefined);
|
|
43
|
+
const applies = APPLIES[fact("Applies to") ?? ""];
|
|
44
|
+
const rule = fact("Rule");
|
|
45
|
+
const detect = fact("Detect")?.replace(/`/g, "");
|
|
46
|
+
const ask = fact("Ask");
|
|
47
|
+
const migrate = fact("Migrate");
|
|
48
|
+
if (applies === undefined) problems.push(`${version} ${id}: "- Applies to:" must be "existing code" or "new work only"`);
|
|
49
|
+
if (rule === undefined) problems.push(`${version} ${id}: needs "- Rule:"`);
|
|
50
|
+
if (detect !== undefined && !/^(check|validate):[a-z-]+$/.test(detect)) problems.push(`${version} ${id}: "- Detect:" names a rule as check:<rule> or validate:<rule>`);
|
|
51
|
+
if (applies === "existing" && detect === undefined && ask === undefined) problems.push(`${version} ${id}: a note for existing code needs "- Detect:" or "- Ask:"`);
|
|
52
|
+
if (applies === "existing" && migrate === undefined) problems.push(`${version} ${id}: a note for existing code needs "- Migrate:"`);
|
|
53
|
+
notes.push({
|
|
54
|
+
id,
|
|
55
|
+
title,
|
|
56
|
+
version,
|
|
57
|
+
appliesTo: applies ?? "new",
|
|
58
|
+
rule: rule ?? "",
|
|
59
|
+
...(detect === undefined ? {} : { detect }),
|
|
60
|
+
...(ask === undefined ? {} : { ask }),
|
|
61
|
+
...(migrate === undefined ? {} : { migrate }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const ids = notes.map((n) => n.id);
|
|
65
|
+
ids.filter((id, i) => ids.indexOf(id) !== i).forEach((id) => problems.push(`${version}: note ${id} appears twice`));
|
|
66
|
+
return { notes, problems };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Notes after the version the code meets, up to and including the version installed. */
|
|
70
|
+
export function notesBetween(notes , standards , target ) {
|
|
71
|
+
return notes.filter((n) => compareVersions(n.version, standards) > 0 && compareVersions(n.version, target) <= 0);
|
|
72
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { compareVersions } from "./cruze_version.js";
|
|
2
|
+
|
|
3
|
+
/** The first release of Cruze. A project that records no `standards:` meets its expectations. */
|
|
4
|
+
export const FIRST_RELEASE = "0.0.1";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The release that introduced each rule reporting on existing code, keyed as `<command>:<rule>`.
|
|
8
|
+
* A rule newer than the project's `standards:` still reports, but can't fail CI until the project realigns.
|
|
9
|
+
* Every entry needs a realign note in `skills/realign/notes/<version>.md`; the bundle lint checks it.
|
|
10
|
+
*/
|
|
11
|
+
export const RULE_SINCE = {
|
|
12
|
+
"check:test-placement": "0.0.2",
|
|
13
|
+
"validate:outside-module-map": "0.0.2",
|
|
14
|
+
"validate:readme-overview": "0.0.2",
|
|
15
|
+
"validate:feature-size": "0.0.2",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Whether a rule arrived after the version the project's code meets, so it waits for `realign`. */
|
|
19
|
+
export function isPending(command , rule , standards ) {
|
|
20
|
+
const since = RULE_SINCE[`${command}:${rule}`];
|
|
21
|
+
return since !== undefined && compareVersions(since, standards) > 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { parseDocument, } from "yaml";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `check.exceptions` entries that `realign` adds for findings a prefactor change will fix,
|
|
5
|
+
* and that landing that change removes. Edited through the YAML document, so comments survive.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const scheduledReason = (version , note , ref ) => `realign ${version} (${note}): scheduled in ${ref}`;
|
|
14
|
+
|
|
15
|
+
/** The config text with an exception for each finding, naming the change that will fix it. */
|
|
16
|
+
export function withScheduledExceptions(configText , version , ref , findings ) {
|
|
17
|
+
if (findings.length === 0) return configText;
|
|
18
|
+
const doc = parseDocument(configText);
|
|
19
|
+
const listed = new Set(((doc.getIn(["check", "exceptions"]) )?.toJSON() ?? []).map((e) => e.path));
|
|
20
|
+
for (const finding of findings.filter((f, i) => !listed.has(f.path) && findings.findIndex((o) => o.path === f.path) === i)) {
|
|
21
|
+
doc.addIn(["check", "exceptions"], doc.createNode({ path: finding.path, reason: scheduledReason(version, finding.note, ref) }));
|
|
22
|
+
}
|
|
23
|
+
return render(doc);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The config text without the exceptions scheduled in a change, once it lands; unchanged when it had none. */
|
|
27
|
+
export function withoutScheduledExceptions(configText , ref ) {
|
|
28
|
+
const doc = parseDocument(configText);
|
|
29
|
+
const exceptions = doc.getIn(["check", "exceptions"]) ;
|
|
30
|
+
if (exceptions === undefined || !Array.isArray(exceptions.items)) return { text: configText, removed: [] };
|
|
31
|
+
const suffix = `: scheduled in ${ref}`;
|
|
32
|
+
const removed = exceptions.items.map((item) => item.toJSON()).filter((e) => e.reason?.endsWith(suffix)).map((e) => e.path ?? "");
|
|
33
|
+
if (removed.length === 0) return { text: configText, removed: [] };
|
|
34
|
+
exceptions.items = exceptions.items.filter((item) => !(item.toJSON().reason ?? "").endsWith(suffix));
|
|
35
|
+
return { text: render(doc), removed };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Renders without wrapping long lines or padding flow lists, so an edit changes only what it touches. */
|
|
39
|
+
function render(doc ) {
|
|
40
|
+
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
|
41
|
+
}
|
package/package.json
CHANGED