@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.
Files changed (136) 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 +161 -2
  5. package/cli/dist/api.d.ts +3 -0
  6. package/cli/dist/api.js +4 -0
  7. package/cli/dist/check.d.ts +19 -0
  8. package/cli/dist/check.js +40 -0
  9. package/cli/dist/checks/architecture.d.ts +23 -0
  10. package/cli/dist/checks/architecture.js +135 -0
  11. package/cli/dist/checks/canonical-values.d.ts +2 -0
  12. package/cli/dist/checks/canonical-values.js +48 -0
  13. package/cli/dist/checks/commands.d.ts +7 -0
  14. package/cli/dist/checks/commands.js +33 -0
  15. package/cli/dist/checks/context-layout.d.ts +5 -0
  16. package/cli/dist/checks/context-layout.js +83 -0
  17. package/cli/dist/checks/context-size.d.ts +2 -0
  18. package/cli/dist/checks/context-size.js +46 -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 +34 -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-definitions.d.ts +2 -0
  26. package/cli/dist/checks/metrics-definitions.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/stale-copies.d.ts +2 -0
  36. package/cli/dist/checks/stale-copies.js +18 -0
  37. package/cli/dist/checks/standards-copies.d.ts +2 -0
  38. package/cli/dist/checks/standards-copies.js +57 -0
  39. package/cli/dist/checks/tasks.d.ts +2 -0
  40. package/cli/dist/checks/tasks.js +66 -0
  41. package/cli/dist/checks/types.d.ts +23 -0
  42. package/cli/dist/checks/types.js +1 -0
  43. package/cli/dist/checks/wiki-frontmatter.d.ts +3 -0
  44. package/cli/dist/checks/wiki-frontmatter.js +84 -0
  45. package/cli/dist/commands/add.d.ts +3 -0
  46. package/cli/dist/commands/add.js +24 -0
  47. package/cli/dist/commands/approve.d.ts +2 -0
  48. package/cli/dist/commands/approve.js +20 -0
  49. package/cli/dist/commands/check.d.ts +3 -0
  50. package/cli/dist/commands/check.js +19 -0
  51. package/cli/dist/commands/copies.d.ts +3 -0
  52. package/cli/dist/commands/copies.js +23 -0
  53. package/cli/dist/commands/doctor.d.ts +2 -0
  54. package/cli/dist/commands/doctor.js +16 -0
  55. package/cli/dist/commands/hooks.d.ts +6 -0
  56. package/cli/dist/commands/hooks.js +55 -0
  57. package/cli/dist/commands/init.d.ts +3 -0
  58. package/cli/dist/commands/init.js +78 -0
  59. package/cli/dist/commands/learn.d.ts +3 -0
  60. package/cli/dist/commands/learn.js +36 -0
  61. package/cli/dist/commands/mcp.d.ts +2 -0
  62. package/cli/dist/commands/mcp.js +11 -0
  63. package/cli/dist/commands/pipeline.d.ts +3 -0
  64. package/cli/dist/commands/pipeline.js +26 -0
  65. package/cli/dist/commands/search.d.ts +2 -0
  66. package/cli/dist/commands/search.js +13 -0
  67. package/cli/dist/commands/standards.d.ts +3 -0
  68. package/cli/dist/commands/standards.js +26 -0
  69. package/cli/dist/commands/status.d.ts +3 -0
  70. package/cli/dist/commands/status.js +11 -0
  71. package/cli/dist/commands/sync.d.ts +3 -0
  72. package/cli/dist/commands/sync.js +23 -0
  73. package/cli/dist/commands/ui.d.ts +3 -0
  74. package/cli/dist/commands/ui.js +64 -0
  75. package/cli/dist/commands/weigh.d.ts +2 -0
  76. package/cli/dist/commands/weigh.js +16 -0
  77. package/cli/dist/config/load.d.ts +23 -0
  78. package/cli/dist/config/load.js +116 -0
  79. package/cli/dist/config/schema.d.ts +200 -0
  80. package/cli/dist/config/schema.js +116 -0
  81. package/cli/dist/doctor.d.ts +12 -0
  82. package/cli/dist/doctor.js +31 -0
  83. package/cli/dist/format.d.ts +6 -0
  84. package/cli/dist/format.js +35 -0
  85. package/cli/dist/index.d.ts +2 -0
  86. package/cli/dist/index.js +48 -0
  87. package/cli/dist/init.d.ts +46 -0
  88. package/cli/dist/init.js +119 -0
  89. package/cli/dist/learn.d.ts +44 -0
  90. package/cli/dist/learn.js +80 -0
  91. package/cli/dist/lib/env.d.ts +7 -0
  92. package/cli/dist/lib/env.js +6 -0
  93. package/cli/dist/lib/files.d.ts +3 -0
  94. package/cli/dist/lib/files.js +23 -0
  95. package/cli/dist/lib/frontmatter.d.ts +6 -0
  96. package/cli/dist/lib/frontmatter.js +16 -0
  97. package/cli/dist/lib/qmd.d.ts +9 -0
  98. package/cli/dist/lib/qmd.js +13 -0
  99. package/cli/dist/lib/system.d.ts +4 -0
  100. package/cli/dist/lib/system.js +19 -0
  101. package/cli/dist/packs.d.ts +40 -0
  102. package/cli/dist/packs.js +85 -0
  103. package/cli/dist/pipeline.d.ts +20 -0
  104. package/cli/dist/pipeline.js +30 -0
  105. package/cli/dist/standards.d.ts +14 -0
  106. package/cli/dist/standards.js +42 -0
  107. package/cli/dist/status.d.ts +3 -0
  108. package/cli/dist/status.js +26 -0
  109. package/cli/dist/sync.d.ts +19 -0
  110. package/cli/dist/sync.js +30 -0
  111. package/cli/dist/ui.d.ts +15 -0
  112. package/cli/dist/ui.js +69 -0
  113. package/package.json +54 -4
  114. package/packs/expo/pack.yaml +5 -0
  115. package/packs/firebase/pack.yaml +5 -0
  116. package/packs/nextjs/pack.yaml +5 -0
  117. package/packs/react/pack.yaml +5 -0
  118. package/packs/revenuecat/pack.yaml +5 -0
  119. package/packs/uniwind/pack.yaml +5 -0
  120. package/scripts/log-verdict.mjs +56 -0
  121. package/skills/ask/SKILL.md +40 -0
  122. package/skills/decision/SKILL.md +36 -0
  123. package/skills/ingest/SKILL.md +45 -0
  124. package/skills/standards/SKILL.md +16 -0
  125. package/skills/weigh/SKILL.md +160 -0
  126. package/skills/wiki-lint/SKILL.md +67 -0
  127. package/standards/README.md +14 -0
  128. package/standards/agent-contract.md +47 -0
  129. package/standards/constitution.md +104 -0
  130. package/standards/context-layout.md +129 -0
  131. package/standards/dev-methods.md +29 -0
  132. package/standards/wiki-schema.md +71 -0
  133. package/templates/dependency-cruiser/app.cjs +23 -0
  134. package/templates/dependency-cruiser/base.cjs +52 -0
  135. package/templates/dependency-cruiser/package.cjs +31 -0
  136. package/templates/dependency-cruiser/root.cjs +15 -0
@@ -0,0 +1,46 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { REMOVED_WIKI_FOLDERS as REMOVED_FOLDERS } from "../config/schema.js";
4
+ import { wikiPages } from "./wiki-frontmatter.js";
5
+ // Every line here is read by an agent on every task; a cap keeps .context to what it would get wrong without.
6
+ const MAX_LINES = { page: 60, decision: 30, readme: 30, tasks: 100 };
7
+ const DATED_NAME = /^\d{4}-\d{2}-\d{2}-/;
8
+ const lineCount = (repo, file) => readFileSync(join(repo, file), "utf8").trimEnd().split("\n").length;
9
+ export const contextSize = {
10
+ id: "context-size",
11
+ module: "docs",
12
+ description: "pages, decisions, README.md and tasks.md stay under their line caps; decisions are one file per topic; no flow pages",
13
+ run({ repo }) {
14
+ const findings = [];
15
+ const warn = (file, message) => findings.push({ check: "context-size", level: "warn", file, message });
16
+ const overCap = (file, cap) => {
17
+ const lines = lineCount(repo, file);
18
+ if (lines > cap)
19
+ warn(file, `${lines} lines, cap ${cap} — cut what code, designs or git already say`);
20
+ };
21
+ for (const [folder, why] of Object.entries(REMOVED_FOLDERS)) {
22
+ if (existsSync(join(repo, ".context/wiki", folder)))
23
+ warn(`.context/wiki/${folder}`, `${folder}/ is gone: ${why}`);
24
+ }
25
+ for (const file of wikiPages(repo)) {
26
+ const folder = file.split("/")[2];
27
+ if (folder === "synthesis" || folder in REMOVED_FOLDERS)
28
+ continue;
29
+ if (folder === "decisions" && DATED_NAME.test(basename(file))) {
30
+ warn(file, "name a decision by topic and edit it in place — git keeps the history");
31
+ }
32
+ overCap(file, folder === "decisions" ? MAX_LINES.decision : MAX_LINES.page);
33
+ }
34
+ const readme = ".context/README.md";
35
+ const tasks = ".context/tasks.md";
36
+ if (existsSync(join(repo, readme)))
37
+ overCap(readme, MAX_LINES.readme);
38
+ if (existsSync(join(repo, tasks))) {
39
+ overCap(tasks, MAX_LINES.tasks);
40
+ if (/^## (Done|Session Notes)\b/m.test(readFileSync(join(repo, tasks), "utf8"))) {
41
+ warn(tasks, "tasks.md holds only open work — delete done tasks and session notes; the commits record them");
42
+ }
43
+ }
44
+ return findings;
45
+ },
46
+ };
@@ -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,34 @@
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 } from "./stale-copies.js";
6
+ import { standardsCopies } from "./standards-copies.js";
7
+ import { commandChecks } from "./commands.js";
8
+ import { features } from "./features.js";
9
+ import { listing } from "./listing.js";
10
+ import { metricsDefinitions } from "./metrics-definitions.js";
11
+ import { pipeline } from "./pipeline.js";
12
+ import { refs } from "./refs.js";
13
+ import { sharedFiles } from "./shared-files.js";
14
+ import { sources } from "./sources.js";
15
+ import { tasks } from "./tasks.js";
16
+ import { wikiFrontmatter } from "./wiki-frontmatter.js";
17
+ export const ALL_CHECKS = [
18
+ contextLayout,
19
+ wikiFrontmatter,
20
+ contextSize,
21
+ tasks,
22
+ features,
23
+ listing,
24
+ canonicalValues,
25
+ standardsCopies,
26
+ sharedFiles,
27
+ refs,
28
+ staleCopies,
29
+ architecture,
30
+ metricsDefinitions,
31
+ sources,
32
+ pipeline,
33
+ commandChecks,
34
+ ];
@@ -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 staleCopies: Check;
@@ -0,0 +1,18 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // Instruction files for agents other than Claude Code. They are copies of
4
+ // AGENTS.md that nobody reads, so they only drift.
5
+ const OTHER_AGENT_FILES = [".github/copilot-instructions.md", ".cursorrules", ".windsurfrules"];
6
+ export const staleCopies = {
7
+ id: "stale-copies",
8
+ module: null,
9
+ description: "no instruction files for agents that are not used (Copilot, Cursor, Windsurf)",
10
+ run({ repo }) {
11
+ return OTHER_AGENT_FILES.filter((file) => existsSync(join(repo, file))).map((file) => ({
12
+ check: "stale-copies",
13
+ level: "warn",
14
+ file,
15
+ message: "instructions for an agent not in use — delete, Claude reads AGENTS.md",
16
+ }));
17
+ },
18
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const standardsCopies: Check;
@@ -0,0 +1,57 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // Fraction of a shared standard's lines that must appear in a product file
4
+ // before it counts as a copy rather than a reference.
5
+ const COPY_THRESHOLD = 0.6;
6
+ const STANDARD_COPIES = [{ file: ".context/wiki/CLAUDE.md", standard: "standards/wiki-schema.md" }];
7
+ function meaningfulLines(text) {
8
+ return new Set(text
9
+ .split("\n")
10
+ .map((line) => line.trim())
11
+ .filter((line) => line.length > 0 && !line.startsWith("<!--")));
12
+ }
13
+ function sharedFraction(standard, candidate) {
14
+ const wanted = meaningfulLines(standard);
15
+ const present = meaningfulLines(candidate);
16
+ const shared = [...wanted].filter((line) => present.has(line)).length;
17
+ return wanted.size === 0 ? 0 : shared / wanted.size;
18
+ }
19
+ export const standardsCopies = {
20
+ id: "standards-copies",
21
+ module: null,
22
+ description: "shared rules and skills come from canon, not from a copy in the product",
23
+ run({ repo, canonRoot }) {
24
+ const findings = [];
25
+ for (const { file, standard } of STANDARD_COPIES) {
26
+ const productFile = join(repo, file);
27
+ const standardFile = join(canonRoot, standard);
28
+ if (!existsSync(productFile) || !existsSync(standardFile))
29
+ continue;
30
+ const fraction = sharedFraction(readFileSync(standardFile, "utf8"), readFileSync(productFile, "utf8"));
31
+ if (fraction >= COPY_THRESHOLD) {
32
+ findings.push({
33
+ check: "standards-copies",
34
+ level: "warn",
35
+ file,
36
+ message: `${Math.round(fraction * 100)}% copy of canon ${standard} — replace with a pointer`,
37
+ });
38
+ }
39
+ }
40
+ const canonSkills = join(canonRoot, "skills");
41
+ const productSkills = join(repo, ".claude", "skills");
42
+ if (existsSync(canonSkills) && existsSync(productSkills)) {
43
+ const provided = new Set(readdirSync(canonSkills));
44
+ for (const skill of readdirSync(productSkills)) {
45
+ if (!provided.has(skill))
46
+ continue;
47
+ findings.push({
48
+ check: "standards-copies",
49
+ level: "warn",
50
+ file: `.claude/skills/${skill}`,
51
+ message: `canon provides this as canon:${skill} — delete the product copy`,
52
+ });
53
+ }
54
+ }
55
+ return findings;
56
+ },
57
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const tasks: Check;