@freetesh/canon 0.0.0-stage → 0.15.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.
Files changed (134) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/.claude-plugin/plugin.json +5 -0
  3. package/LICENSE +21 -0
  4. package/README.md +146 -2
  5. package/cli/dist/api.d.ts +3 -0
  6. package/cli/dist/api.js +4 -0
  7. package/cli/dist/checks/architecture.d.ts +23 -0
  8. package/cli/dist/checks/architecture.js +135 -0
  9. package/cli/dist/checks/canonical-values.d.ts +2 -0
  10. package/cli/dist/checks/canonical-values.js +48 -0
  11. package/cli/dist/checks/context-layout.d.ts +5 -0
  12. package/cli/dist/checks/context-layout.js +83 -0
  13. package/cli/dist/checks/context-size.d.ts +2 -0
  14. package/cli/dist/checks/context-size.js +46 -0
  15. package/cli/dist/checks/copies.d.ts +3 -0
  16. package/cli/dist/checks/copies.js +73 -0
  17. package/cli/dist/checks/external.d.ts +7 -0
  18. package/cli/dist/checks/external.js +31 -0
  19. package/cli/dist/checks/features.d.ts +2 -0
  20. package/cli/dist/checks/features.js +38 -0
  21. package/cli/dist/checks/index.d.ts +2 -0
  22. package/cli/dist/checks/index.js +33 -0
  23. package/cli/dist/checks/listing.d.ts +2 -0
  24. package/cli/dist/checks/listing.js +51 -0
  25. package/cli/dist/checks/metrics.d.ts +2 -0
  26. package/cli/dist/checks/metrics.js +14 -0
  27. package/cli/dist/checks/pipeline.d.ts +8 -0
  28. package/cli/dist/checks/pipeline.js +34 -0
  29. package/cli/dist/checks/refs.d.ts +2 -0
  30. package/cli/dist/checks/refs.js +92 -0
  31. package/cli/dist/checks/shared-files.d.ts +22 -0
  32. package/cli/dist/checks/shared-files.js +84 -0
  33. package/cli/dist/checks/sources.d.ts +2 -0
  34. package/cli/dist/checks/sources.js +40 -0
  35. package/cli/dist/checks/tasks.d.ts +2 -0
  36. package/cli/dist/checks/tasks.js +66 -0
  37. package/cli/dist/checks/types.d.ts +23 -0
  38. package/cli/dist/checks/types.js +1 -0
  39. package/cli/dist/checks/wiki-frontmatter.d.ts +3 -0
  40. package/cli/dist/checks/wiki-frontmatter.js +84 -0
  41. package/cli/dist/commands/add.d.ts +3 -0
  42. package/cli/dist/commands/add.js +24 -0
  43. package/cli/dist/commands/approve.d.ts +2 -0
  44. package/cli/dist/commands/approve.js +20 -0
  45. package/cli/dist/commands/check.d.ts +3 -0
  46. package/cli/dist/commands/check.js +19 -0
  47. package/cli/dist/commands/copies.d.ts +3 -0
  48. package/cli/dist/commands/copies.js +23 -0
  49. package/cli/dist/commands/doctor.d.ts +2 -0
  50. package/cli/dist/commands/doctor.js +16 -0
  51. package/cli/dist/commands/env.d.ts +7 -0
  52. package/cli/dist/commands/env.js +6 -0
  53. package/cli/dist/commands/hooks.d.ts +6 -0
  54. package/cli/dist/commands/hooks.js +55 -0
  55. package/cli/dist/commands/init.d.ts +3 -0
  56. package/cli/dist/commands/init.js +78 -0
  57. package/cli/dist/commands/learn.d.ts +3 -0
  58. package/cli/dist/commands/learn.js +36 -0
  59. package/cli/dist/commands/pipeline.d.ts +3 -0
  60. package/cli/dist/commands/pipeline.js +26 -0
  61. package/cli/dist/commands/standards.d.ts +3 -0
  62. package/cli/dist/commands/standards.js +26 -0
  63. package/cli/dist/commands/status.d.ts +3 -0
  64. package/cli/dist/commands/status.js +11 -0
  65. package/cli/dist/commands/sync.d.ts +3 -0
  66. package/cli/dist/commands/sync.js +23 -0
  67. package/cli/dist/commands/ui.d.ts +3 -0
  68. package/cli/dist/commands/ui.js +64 -0
  69. package/cli/dist/commands/weigh.d.ts +2 -0
  70. package/cli/dist/commands/weigh.js +16 -0
  71. package/cli/dist/config/load.d.ts +23 -0
  72. package/cli/dist/config/load.js +104 -0
  73. package/cli/dist/config/schema.d.ts +181 -0
  74. package/cli/dist/config/schema.js +106 -0
  75. package/cli/dist/doctor.d.ts +12 -0
  76. package/cli/dist/doctor.js +31 -0
  77. package/cli/dist/format.d.ts +6 -0
  78. package/cli/dist/format.js +35 -0
  79. package/cli/dist/index.d.ts +2 -0
  80. package/cli/dist/index.js +48 -0
  81. package/cli/dist/init.d.ts +46 -0
  82. package/cli/dist/init.js +119 -0
  83. package/cli/dist/learn.d.ts +44 -0
  84. package/cli/dist/learn.js +80 -0
  85. package/cli/dist/lib/files.d.ts +3 -0
  86. package/cli/dist/lib/files.js +23 -0
  87. package/cli/dist/lib/frontmatter.d.ts +6 -0
  88. package/cli/dist/lib/frontmatter.js +16 -0
  89. package/cli/dist/lib/system.d.ts +4 -0
  90. package/cli/dist/lib/system.js +19 -0
  91. package/cli/dist/packs.d.ts +38 -0
  92. package/cli/dist/packs.js +85 -0
  93. package/cli/dist/pipeline.d.ts +20 -0
  94. package/cli/dist/pipeline.js +30 -0
  95. package/cli/dist/run.d.ts +19 -0
  96. package/cli/dist/run.js +40 -0
  97. package/cli/dist/search/mcp.d.ts +2 -0
  98. package/cli/dist/search/mcp.js +11 -0
  99. package/cli/dist/search/qmd.d.ts +9 -0
  100. package/cli/dist/search/qmd.js +13 -0
  101. package/cli/dist/search/search.d.ts +2 -0
  102. package/cli/dist/search/search.js +13 -0
  103. package/cli/dist/standards.d.ts +14 -0
  104. package/cli/dist/standards.js +42 -0
  105. package/cli/dist/status.d.ts +3 -0
  106. package/cli/dist/status.js +26 -0
  107. package/cli/dist/sync.d.ts +19 -0
  108. package/cli/dist/sync.js +30 -0
  109. package/cli/dist/ui.d.ts +15 -0
  110. package/cli/dist/ui.js +69 -0
  111. package/package.json +55 -4
  112. package/packs/expo/pack.yaml +5 -0
  113. package/packs/firebase/pack.yaml +5 -0
  114. package/packs/nextjs/pack.yaml +5 -0
  115. package/packs/react/pack.yaml +5 -0
  116. package/packs/revenuecat/pack.yaml +5 -0
  117. package/packs/uniwind/pack.yaml +5 -0
  118. package/scripts/log-verdict.mjs +56 -0
  119. package/skills/ask/SKILL.md +40 -0
  120. package/skills/decision/SKILL.md +36 -0
  121. package/skills/ingest/SKILL.md +45 -0
  122. package/skills/standards/SKILL.md +16 -0
  123. package/skills/weigh/SKILL.md +160 -0
  124. package/skills/wiki-lint/SKILL.md +67 -0
  125. package/standards/README.md +14 -0
  126. package/standards/agent-contract.md +47 -0
  127. package/standards/constitution.md +104 -0
  128. package/standards/context-layout.md +129 -0
  129. package/standards/dev-methods.md +29 -0
  130. package/standards/wiki-schema.md +71 -0
  131. package/templates/dependency-cruiser/app.cjs +23 -0
  132. package/templates/dependency-cruiser/base.cjs +52 -0
  133. package/templates/dependency-cruiser/package.cjs +31 -0
  134. package/templates/dependency-cruiser/root.cjs +15 -0
@@ -0,0 +1,31 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ const MAX_OUTPUT_LINES = 15;
5
+ function tail(text) {
6
+ return text.trim().split("\n").slice(-MAX_OUTPUT_LINES).join("\n");
7
+ }
8
+ /**
9
+ * Runs each checker a repo or its estate declares under `commands:` in
10
+ * canon.yaml. canon does not know what they check; a non-zero exit is an error
11
+ * reported under the command's own id.
12
+ */
13
+ export const commandChecks = {
14
+ id: "commands",
15
+ module: null,
16
+ description: "every checker declared under `commands:` in canon.yaml passes",
17
+ run({ repo, config }) {
18
+ return config.commands
19
+ .filter((command) => !command.if || existsSync(join(repo, command.if)))
20
+ .filter((command) => !command.module || config.modules.includes(command.module))
21
+ .flatMap((command) => {
22
+ const result = spawnSync("sh", ["-c", command.run], { cwd: repo, encoding: "utf8" });
23
+ if (result.error) {
24
+ return [{ check: command.id, level: "error", file: command.if, message: `could not run: ${result.error.message}` }];
25
+ }
26
+ if (result.status === 0)
27
+ return [];
28
+ return [{ check: command.id, level: "error", file: command.if, message: tail(`${result.stdout}\n${result.stderr}`) }];
29
+ });
30
+ },
31
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const features: Check;
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { listFiles } from "../lib/files.js";
4
+ import { parseFrontmatter } from "../lib/frontmatter.js";
5
+ const TYPES = ["feature", "experiment", "chore", "platform"];
6
+ const STATUSES = ["planned", "building", "shipped", "shelved", "superseded"];
7
+ // A chore or a platform change ships with nothing a user reads; a feature that never shipped keeps its target release.
8
+ const NEEDS_STORE_COPY = ["feature", "experiment"];
9
+ const NEVER_SHIPPED = ["shelved", "superseded"];
10
+ const STORE_COPY = /^## Store copy[ \t]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m;
11
+ export const features = {
12
+ id: "features",
13
+ module: "docs",
14
+ description: "each features/ page has id, type and status from their sets; a released feature has its Store copy blockquote (the What's New text)",
15
+ run({ repo }) {
16
+ const pages = listFiles(repo, ".context/features", (name) => /^\d{4}-.+\.md$/.test(name));
17
+ return pages.flatMap((file) => {
18
+ const findings = [];
19
+ const add = (message) => findings.push({ check: "features", level: "error", file, message });
20
+ const { data, body } = parseFrontmatter(readFileSync(join(repo, file), "utf8"));
21
+ if (!data)
22
+ return [{ check: "features", level: "error", file, message: `no frontmatter (${basename(file)})` }];
23
+ for (const field of ["id", "type", "status"])
24
+ if (!data[field])
25
+ add(`missing ${field}`);
26
+ if (data.type && !TYPES.includes(String(data.type)))
27
+ add(`type ${data.type} is not one of ${TYPES.join(", ")}`);
28
+ if (data.status && !STATUSES.includes(String(data.status)))
29
+ add(`status ${data.status} is not one of ${STATUSES.join(", ")}`);
30
+ const released = /\d+\.\d+\.\d+/.test(String(data.release ?? "")) && !NEVER_SHIPPED.includes(String(data.status));
31
+ const copy = STORE_COPY.exec(body.replace(/<!--[\s\S]*?-->/g, ""));
32
+ if (released && NEEDS_STORE_COPY.includes(String(data.type)) && !(copy && /^>[ \t]*\S/m.test(copy[1]))) {
33
+ add(`ships in ${data.release} with no "> …" line under ## Store copy`);
34
+ }
35
+ return findings;
36
+ });
37
+ },
38
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const ALL_CHECKS: Check[];
@@ -0,0 +1,33 @@
1
+ import { architecture } from "./architecture.js";
2
+ import { canonicalValues } from "./canonical-values.js";
3
+ import { contextLayout } from "./context-layout.js";
4
+ import { contextSize } from "./context-size.js";
5
+ import { staleCopies, standardsCopies } from "./copies.js";
6
+ import { commandChecks } from "./external.js";
7
+ import { features } from "./features.js";
8
+ import { listing } from "./listing.js";
9
+ import { metricsDefinitions } from "./metrics.js";
10
+ import { pipeline } from "./pipeline.js";
11
+ import { refs } from "./refs.js";
12
+ import { sharedFiles } from "./shared-files.js";
13
+ import { sources } from "./sources.js";
14
+ import { tasks } from "./tasks.js";
15
+ import { wikiFrontmatter } from "./wiki-frontmatter.js";
16
+ export const ALL_CHECKS = [
17
+ contextLayout,
18
+ wikiFrontmatter,
19
+ contextSize,
20
+ tasks,
21
+ features,
22
+ listing,
23
+ canonicalValues,
24
+ standardsCopies,
25
+ sharedFiles,
26
+ refs,
27
+ staleCopies,
28
+ architecture,
29
+ metricsDefinitions,
30
+ sources,
31
+ pipeline,
32
+ commandChecks,
33
+ ];
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const listing: Check;
@@ -0,0 +1,51 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { listFiles } from "../lib/files.js";
4
+ const LISTING = ".context/listing";
5
+ // supply and deliver treat every folder in metadata/ as a locale, so a stray one aborts the upload.
6
+ const NOT_LOCALES = ["review_information", "trade_representative_contact_information", "app_clip_review_information"];
7
+ const LOCALE = /^[a-z]{2,3}(-([A-Za-z]{2}|[A-Za-z]{4}))?$/;
8
+ const PLAY_TEXT = ["title.txt", "short_description.txt", "full_description.txt"];
9
+ const PLAY_IMAGE_FOLDERS = ["phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", "tvScreenshots", "wearScreenshots"];
10
+ const folders = (path) => (existsSync(path) ? readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : []);
11
+ export const listing = {
12
+ id: "listing",
13
+ module: "listing",
14
+ description: "listing/ is fastlane's tree: metadata/ per store, locale folders only, Play's three text files, fastlane image folders, zero-padded screenshots",
15
+ run({ repo }) {
16
+ const root = join(repo, LISTING);
17
+ if (!existsSync(root))
18
+ return [];
19
+ const findings = [];
20
+ const add = (file, message) => findings.push({ check: "listing", level: "error", file, message });
21
+ for (const store of ["android", "ios"]) {
22
+ if (!existsSync(join(root, store)))
23
+ continue;
24
+ const metadata = join(root, store, "metadata");
25
+ if (!existsSync(metadata)) {
26
+ add(`${LISTING}/${store}/`, "no metadata/ folder");
27
+ continue;
28
+ }
29
+ for (const name of folders(metadata)) {
30
+ if (!NOT_LOCALES.includes(name) && !LOCALE.test(name))
31
+ add(`${LISTING}/${store}/metadata/${name}`, "not a locale — fastlane would upload it as one");
32
+ }
33
+ }
34
+ for (const locale of folders(join(root, "android", "metadata"))) {
35
+ const at = `${LISTING}/android/metadata/${locale}`;
36
+ for (const file of PLAY_TEXT)
37
+ if (!existsSync(join(repo, at, file)))
38
+ add(`${at}/${file}`, "missing");
39
+ for (const name of folders(join(repo, at, "images"))) {
40
+ if (!PLAY_IMAGE_FOLDERS.includes(name))
41
+ add(`${at}/images/${name}`, "not a fastlane image folder");
42
+ }
43
+ }
44
+ // Play ships screenshots in file-name order, so `2-` sorts after `10-`.
45
+ for (const file of listFiles(repo, LISTING, (name) => name.endsWith(".png"))) {
46
+ if (/[Ss]creenshots\//.test(file) && /\/\d-[^/]*$/.test(file))
47
+ add(file, "unpadded screenshot number — name it NN-<screen>.png");
48
+ }
49
+ return findings;
50
+ },
51
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const metricsDefinitions: Check;
@@ -0,0 +1,14 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const DEFINITIONS = ".context/metrics/definitions.yml";
4
+ export const metricsDefinitions = {
5
+ id: "metrics-definitions",
6
+ module: "metrics",
7
+ description: "metrics/definitions.yml names the numbers the weigh gate measures value against",
8
+ run({ repo }) {
9
+ if (existsSync(join(repo, DEFINITIONS)))
10
+ return [];
11
+ // A warning: most products turn metrics on before they define a single number.
12
+ return [{ check: "metrics-definitions", level: "warn", file: DEFINITIONS, message: "no metric definitions — the weigh gate cannot name the metric a request moves" }];
13
+ },
14
+ };
@@ -0,0 +1,8 @@
1
+ import type { Check } from "./types.ts";
2
+ /**
3
+ * Stages run in the order canon.yaml lists them. A stage has started once one
4
+ * of its `done` paths exists; a gated stage must be approved before any later
5
+ * stage starts. Each unapproved gate is reported once, at the first later
6
+ * stage that started.
7
+ */
8
+ export declare const pipeline: Check;
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readApprovals } from "../pipeline.js";
4
+ /**
5
+ * Stages run in the order canon.yaml lists them. A stage has started once one
6
+ * of its `done` paths exists; a gated stage must be approved before any later
7
+ * stage starts. Each unapproved gate is reported once, at the first later
8
+ * stage that started.
9
+ */
10
+ export const pipeline = {
11
+ id: "pipeline",
12
+ module: null,
13
+ description: "no stage starts before every earlier gate is approved",
14
+ run({ repo, config }) {
15
+ const approved = new Set(readApprovals(repo).map((approval) => approval.stage));
16
+ const startedAt = config.pipeline.map((stage) => stage.done.find((path) => existsSync(join(repo, path))));
17
+ return config.pipeline.flatMap((gate, index) => {
18
+ if (!gate.gate || approved.has(gate.id))
19
+ return [];
20
+ const later = config.pipeline.findIndex((_, i) => i > index && startedAt[i] !== undefined);
21
+ if (later === -1)
22
+ return [];
23
+ const stage = config.pipeline[later];
24
+ return [
25
+ {
26
+ check: "pipeline",
27
+ level: "error",
28
+ file: startedAt[later],
29
+ message: `stage "${stage.id}" started before gate "${gate.id}" was approved — review ${gate.id}, then a person runs \`canon approve ${gate.id}\``,
30
+ },
31
+ ];
32
+ });
33
+ },
34
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const refs: Check;
@@ -0,0 +1,92 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join, normalize } from "node:path";
4
+ import { usedCopies } from "./shared-files.js";
5
+ // A `.context/` path not preceded by something that makes it part of a longer one (another
6
+ // product's, an absolute path, a URL). A leading `./` means "beside this file".
7
+ const PATH = /(?<![\w/~.-])(\.\/)?(\.context\/[\w./@+-]*)/g;
8
+ const PLACEHOLDER = /[<{*[$]|YYYY|NNNN/;
9
+ const LINK = /\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g;
10
+ // A line reading a path out of git history names it as it was.
11
+ const FROM_HISTORY = /git (show|checkout|log|diff|restore)\b/;
12
+ // Dated records keep a path as it was on the day.
13
+ const DATED = /(^|\/)CHANGELOG\.md$|^\.context\/ops\/incidents\//;
14
+ const BINARY = /\.(png|jpe?g|gif|webp|ico|icns|mp4|mov|pdf|zip|aab|apk|jks|keystore|p8|p12|ttf|otf|woff2?|lock|snap|sqlite|db|bin)$|(^|\/)pnpm-lock\.yaml$/i;
15
+ const FEATURE_FILE = /^\.context\/features\/(\d{4})-[^/]+\.md$/;
16
+ // A pipeline stage's `done:` paths name what will exist once that stage starts.
17
+ const STAGE_DONE = /^\s*-\s*\{[^}]*\bdone:\s*\[/;
18
+ function git(repo, args) {
19
+ const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
20
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
21
+ }
22
+ /** The part of a path that names something real: cut back to the last whole folder before a placeholder. */
23
+ function literalPart(token, restOfLine) {
24
+ let path = token.replace(/[.,;:]+$/, "");
25
+ const cut = PLACEHOLDER.exec(path);
26
+ if (cut || PLACEHOLDER.test(restOfLine.slice(0, 1))) {
27
+ path = cut ? path.slice(0, cut.index) : path;
28
+ path = path.slice(0, path.lastIndexOf("/") + 1);
29
+ }
30
+ return path.replace(/^[./]+|[./]+$/g, "") === "context" ? null : path;
31
+ }
32
+ export const refs = {
33
+ id: "refs",
34
+ // A module, not always on: a repo whose docs describe other repos' .context/ (a tool, a portfolio) does not use it.
35
+ module: "refs",
36
+ description: "every .context/ path a tracked file names exists; a [[link]] names a page (a link to a page not yet written is a note)",
37
+ run({ repo, config }) {
38
+ const listed = git(repo, ["ls-files", "-z"]);
39
+ if (listed.status !== 0)
40
+ return [];
41
+ const files = listed.stdout.split("\0").filter((file) => file && !BINARY.test(file));
42
+ // A copy of the source repo's file carries the source's paths; the product cannot edit it.
43
+ const copied = new Set(usedCopies(config).map((pair) => pair.to));
44
+ const pages = new Set(files.filter((f) => f.startsWith(".context/wiki/") && f.endsWith(".md")).map((f) => basename(f, ".md")));
45
+ const features = new Set(files.flatMap((f) => (FEATURE_FILE.exec(f) ? [`FEAT-${FEATURE_FILE.exec(f)[1]}`] : [])));
46
+ const resolves = (target) => pages.has(target) || features.has(target) || existsSync(join(repo, ".context", `${target}.md`));
47
+ const exists = new Map();
48
+ const present = (path) => {
49
+ if (!exists.has(path)) {
50
+ exists.set(path, existsSync(join(repo, path)) || git(repo, ["check-ignore", "-q", path]).status === 0);
51
+ }
52
+ return exists.get(path);
53
+ };
54
+ const findings = [];
55
+ for (const file of files) {
56
+ if (copied.has(file))
57
+ continue;
58
+ let text;
59
+ try {
60
+ text = readFileSync(join(repo, file), "utf8");
61
+ }
62
+ catch {
63
+ continue; // deleted in the working tree, or a submodule
64
+ }
65
+ const dated = DATED.test(file);
66
+ const links = file.startsWith(".context/") && file.endsWith(".md");
67
+ text.split("\n").forEach((line, index) => {
68
+ const at = `${file}:${index + 1}`;
69
+ const future = file === ".claude/canon.yaml" && STAGE_DONE.test(line);
70
+ if (!dated && !future && line.includes(".context/") && !FROM_HISTORY.test(line)) {
71
+ for (const match of line.matchAll(PATH)) {
72
+ let path = literalPart(match[2], line.slice(match.index + match[0].length));
73
+ if (path === null)
74
+ continue;
75
+ if (match[1])
76
+ path = normalize(join(dirname(file), path)) + (path.endsWith("/") ? "/" : "");
77
+ if (!present(path))
78
+ findings.push({ check: "refs", level: "error", file: at, message: `${path} does not exist` });
79
+ }
80
+ }
81
+ if (links && line.includes("[[")) {
82
+ for (const match of line.matchAll(LINK)) {
83
+ const target = match[1].trim();
84
+ if (!resolves(target))
85
+ findings.push({ check: "refs", level: "warn", file: at, message: `[[${target}]] names no page yet` });
86
+ }
87
+ }
88
+ });
89
+ }
90
+ return findings;
91
+ },
92
+ };
@@ -0,0 +1,22 @@
1
+ import type { Config } from "../config/schema.ts";
2
+ import type { Check } from "./types.ts";
3
+ export interface Pair {
4
+ set: string;
5
+ from: string;
6
+ to: string;
7
+ named: boolean;
8
+ until?: string;
9
+ }
10
+ /** Every file of every set the product uses, as source → product paths. */
11
+ export declare function usedCopies(config: Config): Pair[];
12
+ export interface CopyState {
13
+ pair: Pair;
14
+ state: "same" | "drift" | "missing" | "unknown-set" | "no-source" | "unnamed";
15
+ /** What the product file should hold; set for a drift --sync can repair. */
16
+ want?: string;
17
+ }
18
+ /** Compares every file of every set the product uses with the source repo. */
19
+ export declare function compareCopies(repo: string, config: Config): CopyState[];
20
+ /** Overwrites each drifted copy with the source's. Never adds a missing file. Returns the paths written. */
21
+ export declare function syncCopies(repo: string, config: Config): string[];
22
+ export declare const sharedFiles: Check;
@@ -0,0 +1,84 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { loadConfig } from "../config/load.js";
4
+ // canon init writes the README heading `# .context — <Name>`; a product without a `name:` is read from it.
5
+ const README_NAME = /^# \.context — (.+)$/m;
6
+ const pairOf = (set, file) => typeof file === "string" ? { set, from: file, to: file, named: false } : { set, from: file.from, to: file.to ?? file.from, named: file.named ?? false, until: file.until };
7
+ /** Every file of every set the product uses, as source → product paths. */
8
+ export function usedCopies(config) {
9
+ return config.copies.use.flatMap((set) => (config.copies.sets[set] ?? []).map((file) => pairOf(set, file)));
10
+ }
11
+ function nameOf(repo, config) {
12
+ if (config.name)
13
+ return config.name;
14
+ const readme = join(repo, ".context", "README.md");
15
+ return existsSync(readme) ? README_NAME.exec(readFileSync(readme, "utf8"))?.[1]?.trim() : undefined;
16
+ }
17
+ function split(text, marker) {
18
+ const lines = text.split("\n");
19
+ const at = lines.findIndex((line) => line.includes(marker));
20
+ return at === -1 ? null : { shared: lines.slice(0, at).join("\n"), own: lines.slice(at).join("\n") };
21
+ }
22
+ /** Compares every file of every set the product uses with the source repo. */
23
+ export function compareCopies(repo, config) {
24
+ const { source, sets, use } = config.copies;
25
+ if (use.length === 0 || !source || resolve(source) === resolve(repo))
26
+ return [];
27
+ const unknown = use.filter((set) => !sets[set]).map((set) => ({ pair: { set, from: "", to: "", named: false }, state: "unknown-set" }));
28
+ const productName = nameOf(repo, config);
29
+ const sourceName = nameOf(source, loadConfig(source).config);
30
+ return [
31
+ ...unknown,
32
+ ...usedCopies(config).map((pair) => {
33
+ const from = join(source, pair.from);
34
+ const to = join(repo, pair.to);
35
+ if (!existsSync(from))
36
+ return { pair, state: "no-source" };
37
+ if (!existsSync(to))
38
+ return { pair, state: "missing" };
39
+ if (pair.named && (!productName || !sourceName))
40
+ return { pair, state: "unnamed" };
41
+ const original = pair.named ? readFileSync(from, "utf8").replaceAll(sourceName, productName) : readFileSync(from, "utf8");
42
+ const current = readFileSync(to, "utf8");
43
+ if (!pair.until)
44
+ return original === current ? { pair, state: "same" } : { pair, state: "drift", want: original };
45
+ const theirs = split(original, pair.until);
46
+ const ours = split(current, pair.until);
47
+ if (theirs && ours && theirs.shared === ours.shared)
48
+ return { pair, state: "same" };
49
+ // Without the marker the product's own part cannot be told from the shared one, so nothing is written.
50
+ return { pair, state: "drift", want: theirs && ours ? `${theirs.shared}\n${ours.own}` : undefined };
51
+ }),
52
+ ];
53
+ }
54
+ /** Overwrites each drifted copy with the source's. Never adds a missing file. Returns the paths written. */
55
+ export function syncCopies(repo, config) {
56
+ return compareCopies(repo, config).flatMap(({ pair, state, want }) => {
57
+ if (state !== "drift" || want === undefined)
58
+ return [];
59
+ writeFileSync(join(repo, pair.to), want);
60
+ return [pair.to];
61
+ });
62
+ }
63
+ export const sharedFiles = {
64
+ id: "shared-files",
65
+ module: null,
66
+ description: "every file of every copies set the product uses exists and matches copies.source",
67
+ run({ repo, config }) {
68
+ const source = config.copies.source ? basename(config.copies.source) : "copies.source";
69
+ return compareCopies(repo, config).flatMap(({ pair, state }) => {
70
+ const finding = (file, message) => [{ check: "shared-files", level: "error", file, message }];
71
+ if (state === "unknown-set")
72
+ return finding(".claude/canon.yaml", `copies.use names ${pair.set}, which no copies.sets defines`);
73
+ if (state === "no-source")
74
+ return finding(pair.to, `${source} has no ${pair.from} (set ${pair.set})`);
75
+ if (state === "missing")
76
+ return finding(pair.to, `missing — set ${pair.set} is in use; copy it from ${source}`);
77
+ if (state === "unnamed")
78
+ return finding(pair.to, "no product name to compare with — set name: in canon.yaml");
79
+ if (state === "drift")
80
+ return finding(pair.to, `differs from ${source}${pair.until ? " above the shared block's end" : ""} — \`canon copies --sync\` copies it`);
81
+ return [];
82
+ });
83
+ },
84
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const sources: Check;
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseFrontmatter } from "../lib/frontmatter.js";
4
+ import { wikiPages } from "./wiki-frontmatter.js";
5
+ // canon has no connectors: a source page must say which MCP server, skill or CLI
6
+ // reads it, or that a person reads it by hand (`manual:`), or the weigh gate
7
+ // cannot turn a claim into a fact.
8
+ const ACCESS = /^(mcp|skill|cli|manual):\s*(\S+)/;
9
+ const HOW_TO_QUERY = /^## How to query\s*$/m;
10
+ function accessValue(body, id) {
11
+ const row = body.split("\n").find((line) => line.replace(/\s/g, "").startsWith(`|${id}.ACCESS|`));
12
+ return row ? (row.split("|")[2]?.trim() ?? "") : null;
13
+ }
14
+ export const sources = {
15
+ id: "sources",
16
+ module: "docs",
17
+ description: "each SOURCE.* entity page says how to read it (mcp, skill, manual, or a cli in doctor.tools) and has a How to query section",
18
+ run({ repo, config }) {
19
+ const tools = new Set(config.doctor.tools.map((tool) => tool.tool));
20
+ return wikiPages(repo).flatMap((file) => {
21
+ const { data, body } = parseFrontmatter(readFileSync(join(repo, file), "utf8"));
22
+ const id = String(data?.id ?? "");
23
+ if (!file.startsWith(".context/wiki/entities/") || !id.startsWith("SOURCE."))
24
+ return [];
25
+ const findings = [];
26
+ const add = (message) => findings.push({ check: "sources", level: "error", file, message });
27
+ if (!HOW_TO_QUERY.test(body))
28
+ add("no `## How to query` section");
29
+ const access = accessValue(body, id);
30
+ const match = access === null ? null : ACCESS.exec(access);
31
+ if (access === null)
32
+ add(`no ${id}.ACCESS row — say how to read it`);
33
+ else if (!match)
34
+ add(`${id}.ACCESS is "${access}" — write mcp: <server>, skill: <name>, cli: <command> or manual: <where>`);
35
+ else if (match[1] === "cli" && !tools.has(match[2]))
36
+ add(`cli ${match[2]} is not in doctor.tools — canon doctor cannot report it missing`);
37
+ return findings;
38
+ });
39
+ },
40
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const tasks: Check;
@@ -0,0 +1,66 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const TASKS = ".context/tasks.md";
4
+ const COMMENT = /<!--[\s\S]*?-->/g;
5
+ const ITEM = /^(?:\d+\.|[-*]) \[[ xX]\] +(T\d+\b)?/gm;
6
+ const ISO_DATE = /\d{4}-\d{2}-\d{2}/;
7
+ // A blocker with no way out and no check date is the silent leak the format exists to stop.
8
+ const BLOCKED_FIELDS = ["blocked-by", "unblock", "next-check"];
9
+ function sectionOf(text, name) {
10
+ const clean = text.replace(COMMENT, "");
11
+ const headings = [...clean.matchAll(/^## +(\S[^\n]*?)[ \t]*$/gm)];
12
+ const at = headings.findIndex((heading) => heading[1].startsWith(name));
13
+ if (at === -1)
14
+ return "";
15
+ const start = headings[at].index + headings[at][0].length;
16
+ return clean.slice(start, headings[at + 1]?.index ?? clean.length);
17
+ }
18
+ function fieldOf(item, field) {
19
+ const found = new RegExp(`(?<![\\w-])${field}:[ \\t]*([^\\n]*)`).exec(item);
20
+ return found ? found[1].trim() : null;
21
+ }
22
+ function asDate(value) {
23
+ const found = ISO_DATE.exec(value);
24
+ if (!found)
25
+ return null;
26
+ const date = new Date(`${found[0]}T00:00:00Z`);
27
+ return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== found[0] ? null : date;
28
+ }
29
+ export const tasks = {
30
+ id: "tasks",
31
+ module: null,
32
+ description: "every blocked task has an id, blocked-by, unblock and next-check; a passed date is a note",
33
+ run({ repo, today }) {
34
+ const path = join(repo, TASKS);
35
+ if (!existsSync(path))
36
+ return [];
37
+ const text = readFileSync(path, "utf8");
38
+ const findings = [];
39
+ const add = (level, message) => findings.push({ check: "tasks", level, file: TASKS, message });
40
+ const horizon = /^horizon:\s*(\S+)/m.exec(text);
41
+ const horizonDate = horizon ? asDate(horizon[1]) : null;
42
+ if (horizonDate && horizonDate < today)
43
+ add("warn", `past its horizon (${horizon[1]}) — regenerate it`);
44
+ const blocked = sectionOf(text, "Blocked");
45
+ const starts = [...blocked.matchAll(ITEM)];
46
+ starts.forEach((start, index) => {
47
+ const item = blocked.slice(start.index, starts[index + 1]?.index ?? blocked.length);
48
+ const id = start[1];
49
+ if (!id)
50
+ return add("error", `blocked item without a task id: ${item.split("\n")[0].trim().slice(0, 60)}`);
51
+ for (const field of BLOCKED_FIELDS)
52
+ if (!fieldOf(item, field))
53
+ add("error", `${id} is blocked without ${field}:`);
54
+ // next-check may be prose ("after T010"); only something shaped like a date can be a bad one.
55
+ const check = fieldOf(item, "next-check") ?? "";
56
+ if (ISO_DATE.test(check)) {
57
+ const on = asDate(check);
58
+ if (!on)
59
+ add("error", `${id} next-check: ${check} is not a date`);
60
+ else if (on < today)
61
+ add("warn", `${id} was to be checked on ${check}`);
62
+ }
63
+ });
64
+ return findings;
65
+ },
66
+ };
@@ -0,0 +1,23 @@
1
+ import type { Config, Module } from "../config/schema.ts";
2
+ export type Level = "error" | "warn";
3
+ export interface Finding {
4
+ check: string;
5
+ level: Level;
6
+ message: string;
7
+ file?: string;
8
+ }
9
+ export interface CheckContext {
10
+ repo: string;
11
+ name: string;
12
+ config: Config;
13
+ /** Root of the canon repo, where standards/ and skills/ live. */
14
+ canonRoot: string;
15
+ today: Date;
16
+ }
17
+ export interface Check {
18
+ id: string;
19
+ /** The module that must be enabled for this check to run; null = always. */
20
+ module: Module | null;
21
+ description: string;
22
+ run(context: CheckContext): Finding[];
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare function wikiPages(repo: string): string[];
3
+ export declare const wikiFrontmatter: Check;