@freetesh/canon 0.0.0-stage → 0.16.0
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/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +5 -0
- package/LICENSE +21 -0
- package/README.md +161 -2
- package/cli/dist/api.d.ts +3 -0
- package/cli/dist/api.js +4 -0
- package/cli/dist/check.d.ts +19 -0
- package/cli/dist/check.js +40 -0
- package/cli/dist/checks/architecture.d.ts +23 -0
- package/cli/dist/checks/architecture.js +135 -0
- package/cli/dist/checks/canonical-values.d.ts +2 -0
- package/cli/dist/checks/canonical-values.js +48 -0
- package/cli/dist/checks/commands.d.ts +7 -0
- package/cli/dist/checks/commands.js +33 -0
- package/cli/dist/checks/context-layout.d.ts +5 -0
- package/cli/dist/checks/context-layout.js +83 -0
- package/cli/dist/checks/context-size.d.ts +2 -0
- package/cli/dist/checks/context-size.js +46 -0
- package/cli/dist/checks/features.d.ts +2 -0
- package/cli/dist/checks/features.js +38 -0
- package/cli/dist/checks/index.d.ts +2 -0
- package/cli/dist/checks/index.js +34 -0
- package/cli/dist/checks/listing.d.ts +2 -0
- package/cli/dist/checks/listing.js +51 -0
- package/cli/dist/checks/metrics-definitions.d.ts +2 -0
- package/cli/dist/checks/metrics-definitions.js +14 -0
- package/cli/dist/checks/pipeline.d.ts +8 -0
- package/cli/dist/checks/pipeline.js +34 -0
- package/cli/dist/checks/refs.d.ts +2 -0
- package/cli/dist/checks/refs.js +92 -0
- package/cli/dist/checks/shared-files.d.ts +22 -0
- package/cli/dist/checks/shared-files.js +84 -0
- package/cli/dist/checks/sources.d.ts +2 -0
- package/cli/dist/checks/sources.js +40 -0
- package/cli/dist/checks/stale-copies.d.ts +2 -0
- package/cli/dist/checks/stale-copies.js +18 -0
- package/cli/dist/checks/standards-copies.d.ts +2 -0
- package/cli/dist/checks/standards-copies.js +57 -0
- package/cli/dist/checks/tasks.d.ts +2 -0
- package/cli/dist/checks/tasks.js +66 -0
- package/cli/dist/checks/types.d.ts +23 -0
- package/cli/dist/checks/types.js +1 -0
- package/cli/dist/checks/wiki-frontmatter.d.ts +3 -0
- package/cli/dist/checks/wiki-frontmatter.js +84 -0
- package/cli/dist/commands/add.d.ts +3 -0
- package/cli/dist/commands/add.js +24 -0
- package/cli/dist/commands/approve.d.ts +2 -0
- package/cli/dist/commands/approve.js +20 -0
- package/cli/dist/commands/check.d.ts +3 -0
- package/cli/dist/commands/check.js +19 -0
- package/cli/dist/commands/copies.d.ts +3 -0
- package/cli/dist/commands/copies.js +23 -0
- package/cli/dist/commands/doctor.d.ts +2 -0
- package/cli/dist/commands/doctor.js +16 -0
- package/cli/dist/commands/hooks.d.ts +6 -0
- package/cli/dist/commands/hooks.js +55 -0
- package/cli/dist/commands/init.d.ts +3 -0
- package/cli/dist/commands/init.js +78 -0
- package/cli/dist/commands/learn.d.ts +3 -0
- package/cli/dist/commands/learn.js +36 -0
- package/cli/dist/commands/mcp.d.ts +2 -0
- package/cli/dist/commands/mcp.js +11 -0
- package/cli/dist/commands/pipeline.d.ts +3 -0
- package/cli/dist/commands/pipeline.js +26 -0
- package/cli/dist/commands/search.d.ts +2 -0
- package/cli/dist/commands/search.js +13 -0
- package/cli/dist/commands/standards.d.ts +3 -0
- package/cli/dist/commands/standards.js +26 -0
- package/cli/dist/commands/status.d.ts +3 -0
- package/cli/dist/commands/status.js +11 -0
- package/cli/dist/commands/sync.d.ts +3 -0
- package/cli/dist/commands/sync.js +23 -0
- package/cli/dist/commands/ui.d.ts +3 -0
- package/cli/dist/commands/ui.js +64 -0
- package/cli/dist/commands/weigh.d.ts +2 -0
- package/cli/dist/commands/weigh.js +16 -0
- package/cli/dist/config/load.d.ts +23 -0
- package/cli/dist/config/load.js +116 -0
- package/cli/dist/config/schema.d.ts +200 -0
- package/cli/dist/config/schema.js +116 -0
- package/cli/dist/doctor.d.ts +12 -0
- package/cli/dist/doctor.js +31 -0
- package/cli/dist/format.d.ts +6 -0
- package/cli/dist/format.js +35 -0
- package/cli/dist/index.d.ts +2 -0
- package/cli/dist/index.js +48 -0
- package/cli/dist/init.d.ts +46 -0
- package/cli/dist/init.js +119 -0
- package/cli/dist/learn.d.ts +44 -0
- package/cli/dist/learn.js +80 -0
- package/cli/dist/lib/env.d.ts +7 -0
- package/cli/dist/lib/env.js +6 -0
- package/cli/dist/lib/files.d.ts +3 -0
- package/cli/dist/lib/files.js +23 -0
- package/cli/dist/lib/frontmatter.d.ts +6 -0
- package/cli/dist/lib/frontmatter.js +16 -0
- package/cli/dist/lib/qmd.d.ts +9 -0
- package/cli/dist/lib/qmd.js +13 -0
- package/cli/dist/lib/system.d.ts +4 -0
- package/cli/dist/lib/system.js +19 -0
- package/cli/dist/packs.d.ts +40 -0
- package/cli/dist/packs.js +85 -0
- package/cli/dist/pipeline.d.ts +20 -0
- package/cli/dist/pipeline.js +30 -0
- package/cli/dist/standards.d.ts +14 -0
- package/cli/dist/standards.js +42 -0
- package/cli/dist/status.d.ts +3 -0
- package/cli/dist/status.js +26 -0
- package/cli/dist/sync.d.ts +19 -0
- package/cli/dist/sync.js +30 -0
- package/cli/dist/ui.d.ts +15 -0
- package/cli/dist/ui.js +69 -0
- package/package.json +54 -4
- package/packs/expo/pack.yaml +5 -0
- package/packs/firebase/pack.yaml +5 -0
- package/packs/nextjs/pack.yaml +5 -0
- package/packs/react/pack.yaml +5 -0
- package/packs/revenuecat/pack.yaml +5 -0
- package/packs/uniwind/pack.yaml +5 -0
- package/scripts/log-verdict.mjs +56 -0
- package/skills/ask/SKILL.md +40 -0
- package/skills/decision/SKILL.md +36 -0
- package/skills/ingest/SKILL.md +45 -0
- package/skills/standards/SKILL.md +16 -0
- package/skills/weigh/SKILL.md +160 -0
- package/skills/wiki-lint/SKILL.md +67 -0
- package/standards/README.md +14 -0
- package/standards/agent-contract.md +47 -0
- package/standards/constitution.md +104 -0
- package/standards/context-layout.md +129 -0
- package/standards/dev-methods.md +29 -0
- package/standards/wiki-schema.md +71 -0
- package/templates/dependency-cruiser/app.cjs +23 -0
- package/templates/dependency-cruiser/base.cjs +52 -0
- package/templates/dependency-cruiser/package.cjs +31 -0
- package/templates/dependency-cruiser/root.cjs +15 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { exitCode, formatReports } from "../format.js";
|
|
3
|
+
import { CACHE_DIR, fetchText, runCommand } from "../lib/system.js";
|
|
4
|
+
import { runChecks } from "../check.js";
|
|
5
|
+
import { syncRepo } from "../sync.js";
|
|
6
|
+
export function registerSync(program, env) {
|
|
7
|
+
program
|
|
8
|
+
.command("sync")
|
|
9
|
+
.description("refresh pack docs and the search index, move the pinned canon version, then check")
|
|
10
|
+
.argument("[repo]", "product repo", ".")
|
|
11
|
+
.action(async (repo) => {
|
|
12
|
+
const path = resolve(repo);
|
|
13
|
+
const result = await syncRepo(path, { canonRoot: env.canonRoot, estateRoot: env.estateRoot ?? undefined, cacheDir: CACHE_DIR, fetchText, run: runCommand });
|
|
14
|
+
console.log(`packs refreshed: ${result.packs.join(", ") || "none"}`);
|
|
15
|
+
if (result.base)
|
|
16
|
+
console.log("architecture base updated from canon's template");
|
|
17
|
+
if (result.version)
|
|
18
|
+
console.log(`canon ${result.version.from ?? "unpinned"} → ${result.version.to}`);
|
|
19
|
+
const report = runChecks(path, { canonRoot: env.canonRoot });
|
|
20
|
+
console.log(formatReports([report]));
|
|
21
|
+
process.exitCode = exitCode([report]);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { architectureGraph } from "../checks/architecture.js";
|
|
5
|
+
import { formatReports } from "../format.js";
|
|
6
|
+
import { CACHE_DIR } from "../lib/system.js";
|
|
7
|
+
import { findProducts, runChecks } from "../check.js";
|
|
8
|
+
import { buildSite, quartzConfig } from "../ui.js";
|
|
9
|
+
import { requireEstate } from "../lib/env.js";
|
|
10
|
+
// Quartz v5 has no tagged release yet, so pin the exact commit: an unpinned
|
|
11
|
+
// clone could change the site under us on the next fresh install.
|
|
12
|
+
const QUARTZ_REPO = "https://github.com/jackyzha0/quartz";
|
|
13
|
+
const QUARTZ_COMMIT = "97a2d05f80c4c50534959b1d0d41cc4b3895625e";
|
|
14
|
+
const QUARTZ_DIR = join(CACHE_DIR, "quartz");
|
|
15
|
+
function must(command, args, cwd) {
|
|
16
|
+
const result = spawnSync(command, args, { cwd, stdio: "inherit" });
|
|
17
|
+
if (result.status !== 0)
|
|
18
|
+
throw new Error(`${command} ${args.join(" ")} failed`);
|
|
19
|
+
}
|
|
20
|
+
function ensureQuartz() {
|
|
21
|
+
if (!existsSync(join(QUARTZ_DIR, "node_modules"))) {
|
|
22
|
+
if (!existsSync(QUARTZ_DIR))
|
|
23
|
+
must("git", ["clone", "--quiet", "--filter=blob:none", QUARTZ_REPO, QUARTZ_DIR]);
|
|
24
|
+
must("git", ["checkout", "--quiet", QUARTZ_COMMIT], QUARTZ_DIR);
|
|
25
|
+
must("npm", ["ci", "--silent"], QUARTZ_DIR);
|
|
26
|
+
}
|
|
27
|
+
const defaults = readFileSync(join(QUARTZ_DIR, "quartz.config.default.yaml"), "utf8");
|
|
28
|
+
writeFileSync(join(QUARTZ_DIR, "quartz.config.yaml"), quartzConfig(defaults));
|
|
29
|
+
}
|
|
30
|
+
export function registerUi(program, env) {
|
|
31
|
+
program
|
|
32
|
+
.command("ui")
|
|
33
|
+
.description("serve every product wiki (search, backlinks, graph) and the design galleries on localhost")
|
|
34
|
+
// Uncommon defaults: 8080 and 3001 are usually taken by Docker or a dev server.
|
|
35
|
+
.option("--port <port>", "wiki site port", "4610")
|
|
36
|
+
.option("--designs-port <port>", "design gallery port", "4611")
|
|
37
|
+
.option("--ws-port <port>", "live-reload socket port", "4612")
|
|
38
|
+
.action((options) => {
|
|
39
|
+
ensureQuartz();
|
|
40
|
+
const products = findProducts(requireEstate(env));
|
|
41
|
+
const siteDir = join(CACHE_DIR, "site");
|
|
42
|
+
const designsDir = join(CACHE_DIR, "designs");
|
|
43
|
+
const designsUrl = `http://127.0.0.1:${options.designsPort}`;
|
|
44
|
+
const report = formatReports(products.map((repo) => runChecks(repo, { canonRoot: env.canonRoot, estateRoot: env.estateRoot })));
|
|
45
|
+
buildSite(products, { siteDir, designsDir, designsUrl, report, graph: architectureGraph });
|
|
46
|
+
const designs = spawn("python3", ["-m", "http.server", options.designsPort, "--bind", "127.0.0.1", "--directory", designsDir], {
|
|
47
|
+
stdio: "ignore",
|
|
48
|
+
});
|
|
49
|
+
const site = spawn("npx", ["quartz", "build", "-d", siteDir, "-o", join(CACHE_DIR, "public"), "--serve", "--port", options.port, "--wsPort", options.wsPort], {
|
|
50
|
+
cwd: QUARTZ_DIR,
|
|
51
|
+
stdio: "inherit",
|
|
52
|
+
});
|
|
53
|
+
console.log(`wiki http://localhost:${options.port}\ndesigns ${designsUrl}`);
|
|
54
|
+
const stop = () => {
|
|
55
|
+
designs.kill();
|
|
56
|
+
site.kill();
|
|
57
|
+
};
|
|
58
|
+
process.on("SIGINT", stop);
|
|
59
|
+
site.on("exit", (code) => {
|
|
60
|
+
designs.kill();
|
|
61
|
+
process.exitCode = code ?? 0;
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export function registerWeigh(program) {
|
|
3
|
+
program
|
|
4
|
+
.command("weigh")
|
|
5
|
+
.description("run the decision gate on a request without doing it (calls Claude)")
|
|
6
|
+
.argument("<request...>", "what you want to do")
|
|
7
|
+
.action((words) => {
|
|
8
|
+
const request = words.join(" ");
|
|
9
|
+
const result = spawnSync("claude", ["-p", `/canon:weigh ${request}\n\nWeigh only. Do not start the work.`], {
|
|
10
|
+
stdio: "inherit",
|
|
11
|
+
});
|
|
12
|
+
if (result.error)
|
|
13
|
+
throw new Error(`could not run claude: ${result.error.message}`);
|
|
14
|
+
process.exitCode = result.status ?? 1;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Config, type Kind, type Module } from "./schema.ts";
|
|
2
|
+
export interface ConfigSource {
|
|
3
|
+
layer: "estate" | "product" | "local";
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
6
|
+
export interface LoadOptions {
|
|
7
|
+
/** The estate folder; found by walking up from the repo when not given. */
|
|
8
|
+
estateRoot?: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** The nearest folder at or above `start` whose canon.yaml says `estate: true`, or null. */
|
|
11
|
+
export declare function findEstate(start: string): string | null;
|
|
12
|
+
export declare function inferKind(repo: string): Kind;
|
|
13
|
+
export declare function inferModules(repo: string, kind: Kind): Module[];
|
|
14
|
+
/**
|
|
15
|
+
* Reads the estate, product and local canon.yaml layers over defaults inferred
|
|
16
|
+
* from the repo. Scalars: the most specific layer wins. Lists of names add up
|
|
17
|
+
* (packs, standards); lists of records merge by their key (commands, tools,
|
|
18
|
+
* wiki types, context folders).
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadConfig(repo: string, options?: LoadOptions): {
|
|
21
|
+
config: Config;
|
|
22
|
+
sources: ConfigSource[];
|
|
23
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { MODULES_BY_KIND, layerSchema } from "./schema.js";
|
|
5
|
+
const EXPO_MARKERS = ["app.config.ts", "app.config.js", "app.json"];
|
|
6
|
+
const WEB_MARKERS = ["next.config.ts", "next.config.js", "next.config.mjs", "vite.config.ts"];
|
|
7
|
+
function configPath(folder) {
|
|
8
|
+
return join(folder, ".claude", "canon.yaml");
|
|
9
|
+
}
|
|
10
|
+
function readLayer(path) {
|
|
11
|
+
const parsed = layerSchema.safeParse(parse(readFileSync(path, "utf8")) ?? {});
|
|
12
|
+
if (!parsed.success) {
|
|
13
|
+
const problems = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
14
|
+
throw new Error(`${path} is invalid:\n ${problems.join("\n ")}`);
|
|
15
|
+
}
|
|
16
|
+
return parsed.data;
|
|
17
|
+
}
|
|
18
|
+
/** The nearest folder at or above `start` whose canon.yaml says `estate: true`, or null. */
|
|
19
|
+
export function findEstate(start) {
|
|
20
|
+
let folder = resolve(start);
|
|
21
|
+
while (true) {
|
|
22
|
+
const path = configPath(folder);
|
|
23
|
+
if (existsSync(path) && readLayer(path).estate === true)
|
|
24
|
+
return folder;
|
|
25
|
+
const parent = dirname(folder);
|
|
26
|
+
if (parent === folder)
|
|
27
|
+
return null;
|
|
28
|
+
folder = parent;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The repo root plus each app in a monorepo's apps/ folder. */
|
|
32
|
+
function appRoots(repo) {
|
|
33
|
+
const apps = join(repo, "apps");
|
|
34
|
+
if (!existsSync(apps))
|
|
35
|
+
return [repo];
|
|
36
|
+
const children = readdirSync(apps, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
37
|
+
return [repo, ...children.map((entry) => join(apps, entry.name))];
|
|
38
|
+
}
|
|
39
|
+
/** A root package.json that ships a CLI or exports a library. */
|
|
40
|
+
function isPackage(repo) {
|
|
41
|
+
const file = join(repo, "package.json");
|
|
42
|
+
if (!existsSync(file))
|
|
43
|
+
return false;
|
|
44
|
+
const pkg = JSON.parse(readFileSync(file, "utf8"));
|
|
45
|
+
return Boolean(pkg.bin || pkg.exports || pkg.main) && !pkg.private;
|
|
46
|
+
}
|
|
47
|
+
export function inferKind(repo) {
|
|
48
|
+
const roots = appRoots(repo);
|
|
49
|
+
const anyHas = (markers) => roots.some((root) => markers.some((file) => existsSync(join(root, file))));
|
|
50
|
+
const mobile = anyHas(EXPO_MARKERS);
|
|
51
|
+
const web = anyHas(WEB_MARKERS);
|
|
52
|
+
if (mobile && web)
|
|
53
|
+
return "mobile-and-web";
|
|
54
|
+
if (mobile)
|
|
55
|
+
return "mobile";
|
|
56
|
+
if (web)
|
|
57
|
+
return "web";
|
|
58
|
+
return isPackage(repo) ? "package" : "content";
|
|
59
|
+
}
|
|
60
|
+
export function inferModules(repo, kind) {
|
|
61
|
+
const modules = MODULES_BY_KIND[kind];
|
|
62
|
+
const hasDesigns = existsSync(join(repo, ".context", "designs"));
|
|
63
|
+
return hasDesigns && !modules.includes("design") ? [...modules, "design"] : modules;
|
|
64
|
+
}
|
|
65
|
+
function lastDefined(values) {
|
|
66
|
+
return values.filter((value) => value !== undefined).pop();
|
|
67
|
+
}
|
|
68
|
+
function mergeBy(lists, key) {
|
|
69
|
+
const merged = new Map();
|
|
70
|
+
for (const item of lists.flat())
|
|
71
|
+
merged.set(key(item), item);
|
|
72
|
+
return [...merged.values()];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reads the estate, product and local canon.yaml layers over defaults inferred
|
|
76
|
+
* from the repo. Scalars: the most specific layer wins. Lists of names add up
|
|
77
|
+
* (packs, standards); lists of records merge by their key (commands, tools,
|
|
78
|
+
* wiki types, context folders).
|
|
79
|
+
*/
|
|
80
|
+
export function loadConfig(repo, options = {}) {
|
|
81
|
+
const estateRoot = options.estateRoot === undefined ? findEstate(dirname(resolve(repo))) : options.estateRoot;
|
|
82
|
+
const candidates = [
|
|
83
|
+
...(estateRoot && resolve(estateRoot) !== resolve(repo) ? [{ layer: "estate", path: configPath(estateRoot) }] : []),
|
|
84
|
+
{ layer: "product", path: configPath(repo) },
|
|
85
|
+
{ layer: "local", path: join(repo, ".claude", "canon.local.yaml") },
|
|
86
|
+
];
|
|
87
|
+
const sources = candidates.filter((source) => existsSync(source.path));
|
|
88
|
+
const layers = sources.map((source) => ({ data: readLayer(source.path), base: dirname(dirname(source.path)) }));
|
|
89
|
+
const pick = (key) => layers.map((layer) => layer.data[key]);
|
|
90
|
+
const kind = lastDefined(pick("kind")) ?? inferKind(repo);
|
|
91
|
+
const config = {
|
|
92
|
+
canon: lastDefined(pick("canon")),
|
|
93
|
+
kind,
|
|
94
|
+
modules: lastDefined(pick("modules")) ?? inferModules(repo, kind),
|
|
95
|
+
expo: Object.assign({}, ...pick("expo")),
|
|
96
|
+
packs: [...new Set(pick("packs").flatMap((packs) => packs ?? []))].sort(),
|
|
97
|
+
standards: [...new Set(layers.flatMap((layer) => (layer.data.standards ?? []).map((dir) => resolve(layer.base, dir))))],
|
|
98
|
+
commands: mergeBy(pick("commands").map((list) => list ?? []), (command) => command.id),
|
|
99
|
+
doctor: { tools: mergeBy(pick("doctor").map((doctor) => doctor?.tools ?? []), (tool) => tool.tool) },
|
|
100
|
+
weigh: { weights: Object.assign({}, ...pick("weigh").map((weigh) => weigh?.weights)) },
|
|
101
|
+
wiki: { types: mergeBy(pick("wiki").map((wiki) => wiki?.types ?? []), (entry) => entry.type) },
|
|
102
|
+
context: { folders: mergeBy(pick("context").map((context) => context?.folders ?? []), (entry) => entry.folder) },
|
|
103
|
+
pipeline: lastDefined(pick("pipeline")) ?? [],
|
|
104
|
+
name: lastDefined(pick("name")),
|
|
105
|
+
copies: {
|
|
106
|
+
source: lastDefined(layers.map((layer) => (layer.data.copies?.source ? resolve(layer.base, layer.data.copies.source) : undefined))),
|
|
107
|
+
sets: Object.assign({}, ...pick("copies").map((copies) => copies?.sets)),
|
|
108
|
+
use: [...new Set(pick("copies").flatMap((copies) => copies?.use ?? []))],
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
if (config.name === undefined)
|
|
112
|
+
delete config.name;
|
|
113
|
+
if (Object.keys(config.expo ?? {}).length === 0)
|
|
114
|
+
delete config.expo;
|
|
115
|
+
return { config, sources };
|
|
116
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** What a repo ships: an app to the stores, a site to the web, both, a package to a registry, or no code at all. */
|
|
3
|
+
export declare const KINDS: readonly ["mobile", "web", "mobile-and-web", "package", "content"];
|
|
4
|
+
export declare const MODULES: readonly ["docs", "design", "knowledge", "metrics", "code", "architecture", "listing", "refs"];
|
|
5
|
+
/**
|
|
6
|
+
* The wiki page types every repo has, and the folder each lives in. A repo adds more under `wiki.types`.
|
|
7
|
+
* No flow type: a journey worth keeping is a concept. A surface page is a screen's notes, only where it has real ones.
|
|
8
|
+
*/
|
|
9
|
+
export declare const WIKI_FOLDER_BY_TYPE: Record<string, string>;
|
|
10
|
+
/** Wiki folders that no longer exist, and what to do with what was in them. */
|
|
11
|
+
export declare const REMOVED_WIKI_FOLDERS: Record<string, string>;
|
|
12
|
+
/** The `.context/` folders every repo may have. A repo adds more under `context.folders`. */
|
|
13
|
+
export declare const CONTEXT_FOLDERS: string[];
|
|
14
|
+
export type Kind = (typeof KINDS)[number];
|
|
15
|
+
export type Module = (typeof MODULES)[number];
|
|
16
|
+
declare const command: z.ZodObject<{
|
|
17
|
+
id: z.ZodString;
|
|
18
|
+
run: z.ZodString;
|
|
19
|
+
if: z.ZodOptional<z.ZodString>;
|
|
20
|
+
module: z.ZodOptional<z.ZodEnum<{
|
|
21
|
+
architecture: "architecture";
|
|
22
|
+
code: "code";
|
|
23
|
+
design: "design";
|
|
24
|
+
docs: "docs";
|
|
25
|
+
knowledge: "knowledge";
|
|
26
|
+
listing: "listing";
|
|
27
|
+
metrics: "metrics";
|
|
28
|
+
refs: "refs";
|
|
29
|
+
}>>;
|
|
30
|
+
kinds: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
31
|
+
content: "content";
|
|
32
|
+
mobile: "mobile";
|
|
33
|
+
"mobile-and-web": "mobile-and-web";
|
|
34
|
+
package: "package";
|
|
35
|
+
web: "web";
|
|
36
|
+
}>>>;
|
|
37
|
+
}, z.core.$strict>;
|
|
38
|
+
declare const wikiType: z.ZodObject<{
|
|
39
|
+
type: z.ZodString;
|
|
40
|
+
folder: z.ZodString;
|
|
41
|
+
reason: z.ZodString;
|
|
42
|
+
sections: z.ZodArray<z.ZodString>;
|
|
43
|
+
}, z.core.$strict>;
|
|
44
|
+
declare const contextFolder: z.ZodObject<{
|
|
45
|
+
folder: z.ZodString;
|
|
46
|
+
reason: z.ZodString;
|
|
47
|
+
}, z.core.$strict>;
|
|
48
|
+
/** A file copied from the source repo: the same path on both sides, or `from` one path `to` another. */
|
|
49
|
+
declare const copyFile: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
50
|
+
from: z.ZodString;
|
|
51
|
+
to: z.ZodOptional<z.ZodString>;
|
|
52
|
+
named: z.ZodOptional<z.ZodBoolean>;
|
|
53
|
+
until: z.ZodOptional<z.ZodString>;
|
|
54
|
+
}, z.core.$strict>]>;
|
|
55
|
+
declare const stage: z.ZodObject<{
|
|
56
|
+
id: z.ZodString;
|
|
57
|
+
done: z.ZodArray<z.ZodString>;
|
|
58
|
+
gate: z.ZodDefault<z.ZodBoolean>;
|
|
59
|
+
}, z.core.$strict>;
|
|
60
|
+
declare const tool: z.ZodObject<{
|
|
61
|
+
tool: z.ZodString;
|
|
62
|
+
why: z.ZodString;
|
|
63
|
+
hint: z.ZodString;
|
|
64
|
+
}, z.core.$strict>;
|
|
65
|
+
/** One layer of `.claude/canon.yaml` as written: every field optional. */
|
|
66
|
+
export declare const layerSchema: z.ZodObject<{
|
|
67
|
+
estate: z.ZodOptional<z.ZodBoolean>;
|
|
68
|
+
canon: z.ZodOptional<z.ZodString>;
|
|
69
|
+
kind: z.ZodOptional<z.ZodEnum<{
|
|
70
|
+
content: "content";
|
|
71
|
+
mobile: "mobile";
|
|
72
|
+
"mobile-and-web": "mobile-and-web";
|
|
73
|
+
package: "package";
|
|
74
|
+
web: "web";
|
|
75
|
+
}>>;
|
|
76
|
+
modules: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
77
|
+
architecture: "architecture";
|
|
78
|
+
code: "code";
|
|
79
|
+
design: "design";
|
|
80
|
+
docs: "docs";
|
|
81
|
+
knowledge: "knowledge";
|
|
82
|
+
listing: "listing";
|
|
83
|
+
metrics: "metrics";
|
|
84
|
+
refs: "refs";
|
|
85
|
+
}>>>;
|
|
86
|
+
expo: z.ZodOptional<z.ZodObject<{
|
|
87
|
+
sdk: z.ZodOptional<z.ZodNumber>;
|
|
88
|
+
}, z.core.$strip>>;
|
|
89
|
+
packs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
90
|
+
standards: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
91
|
+
commands: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
92
|
+
id: z.ZodString;
|
|
93
|
+
run: z.ZodString;
|
|
94
|
+
if: z.ZodOptional<z.ZodString>;
|
|
95
|
+
module: z.ZodOptional<z.ZodEnum<{
|
|
96
|
+
architecture: "architecture";
|
|
97
|
+
code: "code";
|
|
98
|
+
design: "design";
|
|
99
|
+
docs: "docs";
|
|
100
|
+
knowledge: "knowledge";
|
|
101
|
+
listing: "listing";
|
|
102
|
+
metrics: "metrics";
|
|
103
|
+
refs: "refs";
|
|
104
|
+
}>>;
|
|
105
|
+
kinds: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
106
|
+
content: "content";
|
|
107
|
+
mobile: "mobile";
|
|
108
|
+
"mobile-and-web": "mobile-and-web";
|
|
109
|
+
package: "package";
|
|
110
|
+
web: "web";
|
|
111
|
+
}>>>;
|
|
112
|
+
}, z.core.$strict>>>;
|
|
113
|
+
doctor: z.ZodOptional<z.ZodObject<{
|
|
114
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
115
|
+
tool: z.ZodString;
|
|
116
|
+
why: z.ZodString;
|
|
117
|
+
hint: z.ZodString;
|
|
118
|
+
}, z.core.$strict>>>;
|
|
119
|
+
}, z.core.$strip>>;
|
|
120
|
+
weigh: z.ZodOptional<z.ZodObject<{
|
|
121
|
+
weights: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
122
|
+
}, z.core.$strip>>;
|
|
123
|
+
wiki: z.ZodOptional<z.ZodObject<{
|
|
124
|
+
types: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
125
|
+
type: z.ZodString;
|
|
126
|
+
folder: z.ZodString;
|
|
127
|
+
reason: z.ZodString;
|
|
128
|
+
sections: z.ZodArray<z.ZodString>;
|
|
129
|
+
}, z.core.$strict>>>;
|
|
130
|
+
}, z.core.$strip>>;
|
|
131
|
+
context: z.ZodOptional<z.ZodObject<{
|
|
132
|
+
folders: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
133
|
+
folder: z.ZodString;
|
|
134
|
+
reason: z.ZodString;
|
|
135
|
+
}, z.core.$strict>>>;
|
|
136
|
+
}, z.core.$strip>>;
|
|
137
|
+
pipeline: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
138
|
+
id: z.ZodString;
|
|
139
|
+
done: z.ZodArray<z.ZodString>;
|
|
140
|
+
gate: z.ZodDefault<z.ZodBoolean>;
|
|
141
|
+
}, z.core.$strict>>>;
|
|
142
|
+
name: z.ZodOptional<z.ZodString>;
|
|
143
|
+
copies: z.ZodOptional<z.ZodObject<{
|
|
144
|
+
source: z.ZodOptional<z.ZodString>;
|
|
145
|
+
sets: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
146
|
+
from: z.ZodString;
|
|
147
|
+
to: z.ZodOptional<z.ZodString>;
|
|
148
|
+
named: z.ZodOptional<z.ZodBoolean>;
|
|
149
|
+
until: z.ZodOptional<z.ZodString>;
|
|
150
|
+
}, z.core.$strict>]>>>>;
|
|
151
|
+
use: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
152
|
+
}, z.core.$strict>>;
|
|
153
|
+
}, z.core.$strict>;
|
|
154
|
+
export type Layer = z.infer<typeof layerSchema>;
|
|
155
|
+
export type CommandSpec = z.infer<typeof command>;
|
|
156
|
+
export type ToolSpec = z.infer<typeof tool>;
|
|
157
|
+
export type WikiTypeSpec = z.infer<typeof wikiType>;
|
|
158
|
+
export type ContextFolderSpec = z.infer<typeof contextFolder>;
|
|
159
|
+
export type StageSpec = z.infer<typeof stage>;
|
|
160
|
+
export type CopyFileSpec = z.infer<typeof copyFile>;
|
|
161
|
+
export interface Config {
|
|
162
|
+
/** The canon version this repo was last synced to. */
|
|
163
|
+
canon?: string;
|
|
164
|
+
kind: Kind;
|
|
165
|
+
modules: Module[];
|
|
166
|
+
expo?: {
|
|
167
|
+
sdk?: number;
|
|
168
|
+
};
|
|
169
|
+
packs: string[];
|
|
170
|
+
/** Absolute standards folders, estate first; a later file of the same name wins. */
|
|
171
|
+
standards: string[];
|
|
172
|
+
commands: CommandSpec[];
|
|
173
|
+
doctor: {
|
|
174
|
+
tools: ToolSpec[];
|
|
175
|
+
};
|
|
176
|
+
weigh: {
|
|
177
|
+
weights: Record<string, number>;
|
|
178
|
+
};
|
|
179
|
+
/** Wiki page types added to the built-in six. */
|
|
180
|
+
wiki: {
|
|
181
|
+
types: WikiTypeSpec[];
|
|
182
|
+
};
|
|
183
|
+
/** `.context/` folders added to the built-in ten. */
|
|
184
|
+
context: {
|
|
185
|
+
folders: ContextFolderSpec[];
|
|
186
|
+
};
|
|
187
|
+
/** Ordered stages; empty when no layer declares one. */
|
|
188
|
+
pipeline: StageSpec[];
|
|
189
|
+
name?: string;
|
|
190
|
+
/** `source` is absolute, resolved from the layer that set it. `use` adds up across layers. */
|
|
191
|
+
copies: {
|
|
192
|
+
source?: string;
|
|
193
|
+
sets: Record<string, CopyFileSpec[]>;
|
|
194
|
+
use: string[];
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
export declare const MODULES_BY_KIND: Record<Kind, Module[]>;
|
|
198
|
+
/** A repo that ships both counts as mobile and as web: it gets both kinds' packs. */
|
|
199
|
+
export declare function kindCovers(kind: Kind, wanted: Kind): boolean;
|
|
200
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** What a repo ships: an app to the stores, a site to the web, both, a package to a registry, or no code at all. */
|
|
3
|
+
export const KINDS = ["mobile", "web", "mobile-and-web", "package", "content"];
|
|
4
|
+
export const MODULES = ["docs", "design", "knowledge", "metrics", "code", "architecture", "listing", "refs"];
|
|
5
|
+
/**
|
|
6
|
+
* The wiki page types every repo has, and the folder each lives in. A repo adds more under `wiki.types`.
|
|
7
|
+
* No flow type: a journey worth keeping is a concept. A surface page is a screen's notes, only where it has real ones.
|
|
8
|
+
*/
|
|
9
|
+
export const WIKI_FOLDER_BY_TYPE = {
|
|
10
|
+
concept: "concepts",
|
|
11
|
+
entity: "entities",
|
|
12
|
+
surface: "surfaces",
|
|
13
|
+
decision: "decisions",
|
|
14
|
+
synthesis: "synthesis",
|
|
15
|
+
};
|
|
16
|
+
/** Wiki folders that no longer exist, and what to do with what was in them. */
|
|
17
|
+
export const REMOVED_WIKI_FOLDERS = {
|
|
18
|
+
flows: "fold a journey worth keeping into a concept page",
|
|
19
|
+
};
|
|
20
|
+
/** The `.context/` folders every repo may have. A repo adds more under `context.folders`. */
|
|
21
|
+
export const CONTEXT_FOLDERS = ["raw", "wiki", "features", "designs", "listing", "marketing", "metrics", "support", "legal", "ops"];
|
|
22
|
+
const principleId = z.string().regex(/^C-\d{2}$/, "principle IDs look like C-07");
|
|
23
|
+
const command = z
|
|
24
|
+
.object({
|
|
25
|
+
id: z.string().regex(/^[a-z0-9-]+$/),
|
|
26
|
+
/** Shell command run from the repo root; a non-zero exit is an error finding. */
|
|
27
|
+
run: z.string().min(1),
|
|
28
|
+
/** Only run when this path exists in the repo. */
|
|
29
|
+
if: z.string().optional(),
|
|
30
|
+
/** Only run in a repo that uses this module. */
|
|
31
|
+
module: z.enum(MODULES).optional(),
|
|
32
|
+
/** Only run in a repo of one of these kinds. */
|
|
33
|
+
kinds: z.array(z.enum(KINDS)).optional(),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
const kebab = z.string().regex(/^[a-z0-9-]+$/, "use lowercase-kebab");
|
|
37
|
+
/** An addition to canon's layout says why, so products drifting apart is written down. */
|
|
38
|
+
const reason = z.string().min(1);
|
|
39
|
+
const wikiType = z
|
|
40
|
+
.object({
|
|
41
|
+
type: kebab,
|
|
42
|
+
folder: kebab,
|
|
43
|
+
reason,
|
|
44
|
+
/** The `## ` headings every page of this type must have, as the schema lists them for the built-in six. */
|
|
45
|
+
sections: z.array(z.string().min(1)).min(1, "list the `## ` sections every page of this type must have"),
|
|
46
|
+
})
|
|
47
|
+
.strict()
|
|
48
|
+
.refine((entry) => !(entry.type in WIKI_FOLDER_BY_TYPE) && !Object.values(WIKI_FOLDER_BY_TYPE).includes(entry.folder), {
|
|
49
|
+
message: "a built-in wiki type or its folder cannot be redefined — add a new one",
|
|
50
|
+
});
|
|
51
|
+
const contextFolder = z.object({ folder: kebab, reason }).strict();
|
|
52
|
+
/** A file copied from the source repo: the same path on both sides, or `from` one path `to` another. */
|
|
53
|
+
const copyFile = z.union([
|
|
54
|
+
z.string().min(1),
|
|
55
|
+
z
|
|
56
|
+
.object({
|
|
57
|
+
from: z.string().min(1),
|
|
58
|
+
to: z.string().min(1).optional(),
|
|
59
|
+
/** Identical except that the source product's name reads as this product's. */
|
|
60
|
+
named: z.boolean().optional(),
|
|
61
|
+
/** Compared only above the first line containing this text; below it is the product's own. */
|
|
62
|
+
until: z.string().min(1).optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict(),
|
|
65
|
+
]);
|
|
66
|
+
const stage = z
|
|
67
|
+
.object({
|
|
68
|
+
id: kebab,
|
|
69
|
+
/** Paths that exist once the stage has started; any one is enough. */
|
|
70
|
+
done: z.array(z.string().min(1)).min(1),
|
|
71
|
+
/** A person must approve this stage (`canon approve <id>`) before any later stage starts. */
|
|
72
|
+
gate: z.boolean().default(false),
|
|
73
|
+
})
|
|
74
|
+
.strict();
|
|
75
|
+
const tool = z.object({ tool: z.string(), why: z.string(), hint: z.string() }).strict();
|
|
76
|
+
/** One layer of `.claude/canon.yaml` as written: every field optional. */
|
|
77
|
+
export const layerSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
/** Marks the folder holding this file as an estate: the parent of several products. */
|
|
80
|
+
estate: z.boolean(),
|
|
81
|
+
canon: z.string(),
|
|
82
|
+
kind: z.enum(KINDS),
|
|
83
|
+
modules: z.array(z.enum(MODULES)),
|
|
84
|
+
expo: z.object({ sdk: z.number().int().positive() }).partial(),
|
|
85
|
+
packs: z.array(z.string().regex(/^[a-z0-9-]+$/, "pack names are lowercase-kebab")),
|
|
86
|
+
/** Folders of extra standards, relative to the folder that holds `.claude/`. */
|
|
87
|
+
standards: z.array(z.string()),
|
|
88
|
+
commands: z.array(command),
|
|
89
|
+
doctor: z.object({ tools: z.array(tool) }).partial(),
|
|
90
|
+
weigh: z.object({ weights: z.record(principleId, z.number().nonnegative()) }).partial(),
|
|
91
|
+
wiki: z.object({ types: z.array(wikiType) }).partial(),
|
|
92
|
+
context: z.object({ folders: z.array(contextFolder) }).partial(),
|
|
93
|
+
/** The stages work goes through, in order. A more specific layer replaces the whole list. */
|
|
94
|
+
pipeline: z.array(stage),
|
|
95
|
+
/** How the product is written in prose, for files shared with only the name changed. */
|
|
96
|
+
name: z.string().min(1),
|
|
97
|
+
/**
|
|
98
|
+
* Files copied from one source repo. The estate names sets of them; a product lists the sets it
|
|
99
|
+
* uses, and only those are held equal to the source.
|
|
100
|
+
*/
|
|
101
|
+
copies: z.object({ source: z.string().min(1), sets: z.record(kebab, z.array(copyFile).min(1)), use: z.array(kebab) }).partial().strict(),
|
|
102
|
+
})
|
|
103
|
+
.partial()
|
|
104
|
+
.strict();
|
|
105
|
+
export const MODULES_BY_KIND = {
|
|
106
|
+
mobile: ["docs", "knowledge", "code", "listing", "refs"],
|
|
107
|
+
web: ["docs", "knowledge", "code", "refs"],
|
|
108
|
+
"mobile-and-web": ["docs", "knowledge", "code", "listing", "refs"],
|
|
109
|
+
// A package's docs describe other repos' .context/ paths, so refs would flag them.
|
|
110
|
+
package: ["docs", "knowledge", "code"],
|
|
111
|
+
content: ["docs", "knowledge", "refs"],
|
|
112
|
+
};
|
|
113
|
+
/** A repo that ships both counts as mobile and as web: it gets both kinds' packs. */
|
|
114
|
+
export function kindCovers(kind, wanted) {
|
|
115
|
+
return kind === wanted || (kind === "mobile-and-web" && (wanted === "mobile" || wanted === "web"));
|
|
116
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ToolSpec } from "./config/schema.ts";
|
|
2
|
+
/** Runs `<command> --version` and returns its first output line, or null if the command is missing. */
|
|
3
|
+
export type Probe = (command: string) => string | null;
|
|
4
|
+
export interface ToolStatus {
|
|
5
|
+
tool: string;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
version?: string;
|
|
8
|
+
why: string;
|
|
9
|
+
hint?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const systemProbe: Probe;
|
|
12
|
+
export declare function runDoctor(probe?: Probe, extra?: ToolSpec[]): ToolStatus[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
// What canon itself needs. A repo or estate adds its own under `doctor.tools`.
|
|
3
|
+
const CORE_TOOLS = [
|
|
4
|
+
{ tool: "node", why: "runs the canon CLI and hooks", hint: "install Node 22+", minimum: [22, 0] },
|
|
5
|
+
{ tool: "git", why: "canon works on git repos", hint: "install git" },
|
|
6
|
+
{ tool: "qmd", why: "knowledge search for Claude", hint: "npm i -g @tobilu/qmd (see the canon README)" },
|
|
7
|
+
];
|
|
8
|
+
const VERSION = /(\d+)\.(\d+)(?:\.(\d+))?/;
|
|
9
|
+
export const systemProbe = (command) => {
|
|
10
|
+
const result = spawnSync(command, ["--version"], { encoding: "utf8" });
|
|
11
|
+
if (result.error || result.status !== 0)
|
|
12
|
+
return null;
|
|
13
|
+
return `${result.stdout}${result.stderr}`.trim().split("\n")[0];
|
|
14
|
+
};
|
|
15
|
+
export function runDoctor(probe = systemProbe, extra = []) {
|
|
16
|
+
const tools = [...CORE_TOOLS, ...extra];
|
|
17
|
+
return tools.map(({ tool, why, hint, minimum }) => {
|
|
18
|
+
const output = probe(tool);
|
|
19
|
+
if (output === null)
|
|
20
|
+
return { tool, ok: false, why, hint };
|
|
21
|
+
const match = VERSION.exec(output);
|
|
22
|
+
const version = match?.[0];
|
|
23
|
+
if (minimum && match) {
|
|
24
|
+
const [major, minor] = [Number(match[1]), Number(match[2])];
|
|
25
|
+
const tooOld = major < minimum[0] || (major === minimum[0] && minor < minimum[1]);
|
|
26
|
+
if (tooOld)
|
|
27
|
+
return { tool, ok: false, version, why, hint };
|
|
28
|
+
}
|
|
29
|
+
return { tool, ok: true, version, why };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Report } from "./check.ts";
|
|
2
|
+
/** Compact, rtk-style text: one header per product, one line per finding, one total line. */
|
|
3
|
+
export declare function formatReports(reports: Report[], options?: {
|
|
4
|
+
quiet?: boolean;
|
|
5
|
+
}): string;
|
|
6
|
+
export declare function exitCode(reports: Report[]): number;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const MARK = { error: "✗", warn: "!" };
|
|
2
|
+
function plural(count, word) {
|
|
3
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
4
|
+
}
|
|
5
|
+
function counts(report) {
|
|
6
|
+
return {
|
|
7
|
+
errors: report.findings.filter((f) => f.level === "error").length,
|
|
8
|
+
warnings: report.findings.filter((f) => f.level === "warn").length,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Compact, rtk-style text: one header per product, one line per finding, one total line. */
|
|
12
|
+
export function formatReports(reports, options = {}) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
let errors = 0;
|
|
15
|
+
let warnings = 0;
|
|
16
|
+
for (const report of reports) {
|
|
17
|
+
const count = counts(report);
|
|
18
|
+
errors += count.errors;
|
|
19
|
+
warnings += count.warnings;
|
|
20
|
+
const clean = report.findings.length === 0;
|
|
21
|
+
if (clean && options.quiet)
|
|
22
|
+
continue;
|
|
23
|
+
const summary = clean ? "ok" : `${plural(count.errors, "error")}, ${plural(count.warnings, "warning")}`;
|
|
24
|
+
lines.push(`${report.name} ${summary}`);
|
|
25
|
+
for (const finding of report.findings) {
|
|
26
|
+
const message = finding.message.replace(/\n/g, "\n ");
|
|
27
|
+
lines.push(` ${MARK[finding.level]} ${finding.check} ${finding.file ?? ""} ${message}`.replace(/ +$/, ""));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
lines.push(`${plural(reports.length, "product")} · ${plural(errors, "error")} · ${plural(warnings, "warning")}`);
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
export function exitCode(reports) {
|
|
34
|
+
return reports.some((report) => report.findings.some((f) => f.level === "error")) ? 1 : 0;
|
|
35
|
+
}
|