@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,15 @@
1
+ {
2
+ "name": "canon",
3
+ "owner": {
4
+ "name": "Ritesh Firodiya"
5
+ },
6
+ "plugins": [
7
+ {
8
+ "name": "canon",
9
+ "source": "./",
10
+ "version": "0.16.0",
11
+ "description": "Keeps your domain knowledge and AI rules in your repos and enforces them, so Claude hallucinates less."
12
+ }
13
+ ],
14
+ "description": "canon: domain knowledge and AI rules kept in your repos, enforced, searchable."
15
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "canon",
3
+ "version": "0.16.0",
4
+ "description": "Keeps your domain knowledge and AI rules in your repos and enforces them, so Claude hallucinates less."
5
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritesh Firodiya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src=".github/assets/icon.svg" width="96" height="96" alt="canon icon">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">canon</h1>
6
+
7
+ <p align="center"><b>Facts, not assumptions.</b> Your rules and knowledge, kept in your repos. Enforced.</p>
8
+
9
+ canon keeps domain knowledge, decisions and AI rules in each repo's `.context/`,
10
+ enforces them, and makes them searchable. Claude searches them before it
11
+ answers, weighs each request against them before it acts, and checks every
12
+ claim in a request against facts. `canon check` fails when code or docs break a
13
+ rule. The AI guesses less, and you redo less work.
14
+
15
+ <p align="center">
16
+ <img src=".github/assets/demo.svg" alt="canon weighs a request. Two of three claims have no facts and the request breaks a decided rule, so it pushes back and stops until the user brings facts, agrees to terms, or drops it.">
17
+ </p>
18
+
19
+ Two parts, free and MIT, that read the same config:
20
+
21
+ - **a Claude Code plugin** — skills, a decision gate, a session hook and a
22
+ search server, so every session in every repo starts with the same rules;
23
+ - **the `canon` CLI** — set up, check, sync and search your repos.
24
+
25
+ A hosted knowledge site, with every repo's `.context/` in one place for a team,
26
+ is a separate paid product built on this CLI.
27
+
28
+ ## What it does
29
+
30
+ - **Weigh before acting.** For a non-trivial request Claude checks the
31
+ constitution, the wiki and decisions, features and tasks, cost against value,
32
+ and the write-less ladder, then answers **DO IT**, **DO IT DIFFERENTLY** or
33
+ **PUSH BACK** with citations. Every verdict is logged; `canon learn` turns
34
+ repeated overrides into proposals.
35
+ - **No assumptions.** Each claim in a request ("users want this", "it is slow")
36
+ is checked against the repo and its data sources and marked backed or not.
37
+ - **Your data sources, your tools.** A repo describes each tool that holds its
38
+ data on a `SOURCE.*` wiki entity that names the MCP server, skill or CLI that
39
+ reads it. The gate queries it read-only to back or reject a claim.
40
+ - **Knowledge that is found.** Every repo's wiki and feature board is indexed
41
+ with [QMD](https://github.com/tobi/qmd); Claude searches before answering.
42
+ - **Context stays small.** A line belongs in `.context/` only if an agent would
43
+ get something wrong without it. Decisions are one file per topic, overwritten
44
+ when the choice changes; `tasks.md` holds open work only; history is git's.
45
+ - **Hard rules are checks.** `canon check` validates layout, wiki frontmatter,
46
+ stale pages, placeholders, size caps, blocked tasks, feature pages, the store
47
+ listing tree, dead `.context/` paths, prices with no canonical home, shared
48
+ files, the import architecture, pipeline gates, and every checker your config
49
+ declares. `canon hooks install` makes every push run it.
50
+ - **Standards with one home.** canon ships defaults; an estate adds or replaces
51
+ them from its own folder; a repo can override again. `canon standards` shows
52
+ what applies.
53
+ - **A repo names only what it uses.** Modules, packs and shared-file sets are
54
+ opt-in; there is no skip list and no setting that turns a check off.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ claude plugin marketplace add ritesh-firodiya/canon-cli
60
+ claude plugin install canon@canon
61
+ npm install -g @freetesh/canon
62
+ npm install -g @tobilu/qmd --allow-scripts=node-llama-cpp,tree-sitter-go,tree-sitter-python,tree-sitter-rust,tree-sitter-typescript,tree-sitter-javascript
63
+ ```
64
+
65
+ Then in any repo: `canon doctor`, `canon init`, `canon check`, `canon sync`.
66
+ Try `/canon:weigh add a dark mode toggle` in a session; record choices with
67
+ `/canon:decision` and ask about them with `/canon:ask`. `/canon:ingest` with no
68
+ argument captures what a session taught.
69
+
70
+ ## CLI
71
+
72
+ ```bash
73
+ canon init # write .claude/canon.yaml and the missing .context files
74
+ canon add # list packs; `canon add expo` adds one and indexes its docs
75
+ canon check # this repo against the standards (exit 1 on any error); --all for the estate
76
+ canon copies --sync # repair drifted shared files from copies.source (never adds one)
77
+ canon hooks install # a pre-push hook that runs `canon check` (--all: every product)
78
+ canon standards # which standards apply here, and where each comes from
79
+ canon sync # refresh pack docs and the search index, move the pinned version
80
+ canon status # version, kind, modules, config layers, check summary
81
+ canon doctor # node, git, qmd — plus any tools your config lists
82
+ canon learn # proposals from overridden verdicts
83
+ canon approve <stage> # a person records a pipeline gate approval
84
+ canon pipeline # the stages this repo resolves to, and where each stands
85
+ canon weigh "<ask>" # the decision gate from the terminal
86
+ canon ui # every wiki and design gallery on localhost
87
+ canon search "<q>" # search the knowledge index
88
+ canon mcp # the search MCP server, for any MCP client
89
+ ```
90
+
91
+ ## Config
92
+
93
+ `.claude/canon.yaml`, read in layers — **estate → repo → local**
94
+ (`canon.local.yaml`, not committed). The estate is the nearest folder above a
95
+ repo whose `canon.yaml` says `estate: true`, or `CANON_ESTATE`.
96
+
97
+ ```yaml
98
+ # <estate>/.claude/canon.yaml — shared by every repo below it
99
+ estate: true
100
+ standards: [.canon/standards] # estate rules; same name replaces a canon default
101
+ commands: # your own checkers, run by `canon check`
102
+ - { id: designs, run: ./scripts/check-designs.sh, if: .context/designs, module: design }
103
+ - { id: designs-first, run: test -d .context/designs, kinds: [mobile, web] } # only repos that ship screens
104
+ copies: # files products copy from one source repo, in named sets
105
+ source: design-system
106
+ sets:
107
+ upload: [scripts/upload.mjs]
108
+ shell-web:
109
+ - { from: designs/mobile/index.html, to: designs/web/index.html, named: true }
110
+ ```
111
+
112
+ ```yaml
113
+ # <repo>/.claude/canon.yaml — written by `canon init`
114
+ canon: 0.16.0
115
+ kind: mobile # mobile | web | mobile-and-web | package | content
116
+ modules: [docs, knowledge, code, listing, refs]
117
+ packs: [expo]
118
+ name: Acme # how the product is written; `named` copies swap it in
119
+ copies:
120
+ use: [upload, shell-web] # the sets this repo uses; only these are held to the source
121
+ ```
122
+
123
+ Scalars: the most specific layer wins. `packs`, `standards` and `copies.use`
124
+ add up across layers; `commands`, `doctor.tools`, `wiki.types` and
125
+ `context.folders` merge by name.
126
+
127
+ ## Layout
128
+
129
+ ```
130
+ .claude-plugin/ plugin manifest and marketplace
131
+ skills/ weigh, standards, ask, ingest, decision, wiki-lint
132
+ agents/ challenger — a second opinion for too-large requests
133
+ hooks/ session start: constitution titles, qmd refresh
134
+ standards/ default standards
135
+ packs/ vendor doc packs
136
+ templates/ files init writes (dependency-cruiser base and starters)
137
+ scripts/ log-verdict.mjs — the verdict log
138
+ evals/ plugin evals and the fixture product
139
+ cli/ the canon CLI (TypeScript, vitest); `@freetesh/canon/api` for tools built on it
140
+ ```
141
+
142
+ Inside `cli/src/`:
143
+
144
+ ```
145
+ index.ts the program: registers every command
146
+ api.ts what other tools import (`@freetesh/canon/api`)
147
+ commands/ one file per command — options and output only
148
+ checks/ one file per check, named by its id; index.ts lists them
149
+ config/ .claude/canon.yaml: schema, layered loading, estate discovery
150
+ lib/ shared helpers: files, frontmatter, env, qmd, system calls
151
+ <name>.ts the logic the commands call: check (+ format for its output), init, packs (add), sync, …
152
+ ```
153
+
154
+ Tests mirror it in `cli/test/`.
155
+
156
+ ## Develop
157
+
158
+ `pnpm check` runs `canon check` on this repo, the typecheck and every test.
159
+ The version lives in `package.json` and `.claude-plugin/plugin.json`; bump both
160
+ with a CHANGELOG entry. A push to main with a new version publishes to npm.
161
+
162
+ MIT licence.
@@ -0,0 +1,3 @@
1
+ export { findEstate } from "./config/load.ts";
2
+ export { findProducts } from "./check.ts";
3
+ export { qmdQuery, qmdRefresh } from "./lib/qmd.ts";
@@ -0,0 +1,4 @@
1
+ // What other tools build on: the canon knowledge site reads repos the way `canon check --all` finds them.
2
+ export { findEstate } from "./config/load.js";
3
+ export { findProducts } from "./check.js";
4
+ export { qmdQuery, qmdRefresh } from "./lib/qmd.js";
@@ -0,0 +1,19 @@
1
+ import type { Check, Finding } from "./checks/types.ts";
2
+ export interface Report {
3
+ repo: string;
4
+ name: string;
5
+ findings: Finding[];
6
+ skipped: Array<{
7
+ check: string;
8
+ why: string;
9
+ }>;
10
+ }
11
+ export interface RunOptions {
12
+ checks?: Check[];
13
+ canonRoot: string;
14
+ estateRoot?: string | null;
15
+ today?: Date;
16
+ }
17
+ export declare function runChecks(repo: string, options: RunOptions): Report;
18
+ /** Product repos in the estate: every direct child folder holding a .context/. */
19
+ export declare function findProducts(estateRoot: string): string[];
@@ -0,0 +1,40 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { ALL_CHECKS } from "./checks/index.js";
4
+ import { loadConfig } from "./config/load.js";
5
+ import { isDirectory } from "./lib/files.js";
6
+ export function runChecks(repo, options) {
7
+ const report = { repo, name: basename(repo), findings: [], skipped: [] };
8
+ if (!isDirectory(repo)) {
9
+ report.findings.push({ check: "repo", level: "error", message: `no folder at ${repo}` });
10
+ return report;
11
+ }
12
+ let config;
13
+ try {
14
+ config = loadConfig(repo, { estateRoot: options.estateRoot }).config;
15
+ }
16
+ catch (error) {
17
+ report.findings.push({ check: "config", level: "error", message: error.message });
18
+ return report;
19
+ }
20
+ for (const check of options.checks ?? ALL_CHECKS) {
21
+ if (check.module && !config.modules.includes(check.module)) {
22
+ report.skipped.push({ check: check.id, why: `module ${check.module} is off` });
23
+ continue;
24
+ }
25
+ try {
26
+ report.findings.push(...check.run({ repo, name: report.name, config, canonRoot: options.canonRoot, today: options.today ?? new Date() }));
27
+ }
28
+ catch (error) {
29
+ report.findings.push({ check: check.id, level: "error", message: `check crashed: ${error.message}` });
30
+ }
31
+ }
32
+ return report;
33
+ }
34
+ /** Product repos in the estate: every direct child folder holding a .context/. */
35
+ export function findProducts(estateRoot) {
36
+ return readdirSync(estateRoot, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory() && existsSync(join(estateRoot, entry.name, ".context")))
38
+ .map((entry) => join(estateRoot, entry.name))
39
+ .sort();
40
+ }
@@ -0,0 +1,23 @@
1
+ import type { Check, Finding } from "./types.ts";
2
+ export declare const RULES_FILES: string[];
3
+ export interface Violation {
4
+ rule: {
5
+ name: string;
6
+ severity: string;
7
+ };
8
+ from: string;
9
+ to: string;
10
+ }
11
+ interface Rules {
12
+ /** Folder the rules apply to, relative to the repo ("." for the root). */
13
+ dir: string;
14
+ file: string;
15
+ }
16
+ /** Every rules file in the repo: the root's, then one per workspace under apps/ and packages/. */
17
+ export declare function findRules(repo: string): Rules[];
18
+ /** One finding per broken rule: how often, and the first few places. */
19
+ export declare function summarize(violations: Violation[]): Finding[];
20
+ export declare const architecture: Check;
21
+ /** The repo's architecture as markdown — one mermaid graph per rules file — or undefined when there is nothing to draw. */
22
+ export declare function architectureGraph(repo: string): string | undefined;
23
+ export {};
@@ -0,0 +1,135 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { join, posix } from "node:path";
5
+ // The names dependency-cruiser itself looks for; `canon init` writes the first.
6
+ export const RULES_FILES = [".dependency-cruiser.cjs", ".dependency-cruiser.js", ".dependency-cruiser.mjs", ".dependency-cruiser.json"];
7
+ const DEFAULT_COLLAPSE = "^(apps|packages)/[^/]+|^[^/]+/[^/]+";
8
+ const SHOWN_PER_RULE = 3;
9
+ // Mermaid refuses diagrams over 50k characters.
10
+ const MERMAID_MAX_CHARS = 50_000;
11
+ const MAX_BUFFER = 64 * 1024 * 1024;
12
+ // Monorepo workspaces whose apps each map `@/` to their own src need one rules file each,
13
+ // because dependency-cruiser resolves aliases from a single tsconfig per run.
14
+ const WORKSPACE_PARENTS = ["apps", "packages"];
15
+ function rulesFile(dir) {
16
+ return RULES_FILES.find((file) => existsSync(join(dir, file)));
17
+ }
18
+ /** Every rules file in the repo: the root's, then one per workspace under apps/ and packages/. */
19
+ export function findRules(repo) {
20
+ const workspaces = WORKSPACE_PARENTS.flatMap((parent) => existsSync(join(repo, parent))
21
+ ? readdirSync(join(repo, parent), { withFileTypes: true })
22
+ .filter((entry) => entry.isDirectory())
23
+ .map((entry) => `${parent}/${entry.name}`)
24
+ .sort()
25
+ : []);
26
+ return [".", ...workspaces].flatMap((dir) => {
27
+ const file = rulesFile(join(repo, dir));
28
+ return file ? [{ dir, file }] : [];
29
+ });
30
+ }
31
+ const fromRoot = (dir, path) => posix.normalize(posix.join(dir, path));
32
+ // The rules are about committed code; a gitignored file (a stale dist/ build) is never shipped.
33
+ function ignoredBy(repo, paths) {
34
+ if (paths.length === 0)
35
+ return new Set();
36
+ const result = spawnSync("git", ["-C", repo, "check-ignore", "--stdin"], { input: paths.join("\n"), encoding: "utf8" });
37
+ return new Set(result.stdout.split("\n").filter(Boolean));
38
+ }
39
+ // The repo's own pinned copy, never a global one, so every machine runs the same version.
40
+ function depcruiseBin(repo) {
41
+ const bin = join(repo, "node_modules", ".bin", "depcruise");
42
+ return existsSync(bin) ? bin : undefined;
43
+ }
44
+ function depcruise(repo, bin, rules, args) {
45
+ return spawnSync(bin, [".", "--config", rules.file, ...args], { cwd: join(repo, rules.dir), encoding: "utf8", maxBuffer: MAX_BUFFER });
46
+ }
47
+ // dependency-cruiser needs a TypeScript it supports (below 7) to read .ts files; without one it
48
+ // skips them and still reports success, so a TypeScript workspace with none read is not a pass.
49
+ function unreadTypeScript(repo, rules, modules) {
50
+ return existsSync(join(repo, rules.dir, "tsconfig.json")) && !modules.some((m) => /\.tsx?$/.test(m.source));
51
+ }
52
+ function typeScriptWarning(rules) {
53
+ const file = fromRoot(rules.dir, "tsconfig.json");
54
+ const message = "no TypeScript files were read, so their imports went unchecked — dependency-cruiser needs `typescript` below 7 installed here";
55
+ return [{ check: "architecture", level: "warn", file, message }];
56
+ }
57
+ /** One finding per broken rule: how often, and the first few places. */
58
+ export function summarize(violations) {
59
+ const byRule = new Map();
60
+ for (const violation of violations.filter((v) => v.rule.severity === "error" || v.rule.severity === "warn")) {
61
+ byRule.set(violation.rule.name, [...(byRule.get(violation.rule.name) ?? []), violation]);
62
+ }
63
+ return [...byRule].map(([name, list]) => {
64
+ const shown = list.slice(0, SHOWN_PER_RULE).map((v) => (v.from === v.to ? v.from : `${v.from} → ${v.to}`));
65
+ const more = list.length > SHOWN_PER_RULE ? ` and ${list.length - SHOWN_PER_RULE} more` : "";
66
+ return {
67
+ check: "architecture",
68
+ level: list[0].rule.severity === "error" ? "error" : "warn",
69
+ file: list[0].from,
70
+ message: `${name} (${list.length}): ${shown.join(", ")}${more}`,
71
+ };
72
+ });
73
+ }
74
+ export const architecture = {
75
+ id: "architecture",
76
+ module: "architecture",
77
+ description: "the repo keeps the import boundaries in its own .dependency-cruiser.cjs",
78
+ run({ repo }) {
79
+ const finding = (message, file) => [{ check: "architecture", level: "error", file, message }];
80
+ const all = findRules(repo);
81
+ if (all.length === 0) {
82
+ return finding("no architecture rules — run `canon init --force --modules ...,architecture` for a starter, or write one", RULES_FILES[0]);
83
+ }
84
+ const bin = depcruiseBin(repo);
85
+ if (!bin)
86
+ return finding("dependency-cruiser is not installed here — add it as a devDependency (e.g. `pnpm add -D dependency-cruiser`)", "package.json");
87
+ return all.flatMap((rules) => {
88
+ const result = depcruise(repo, bin, rules, ["--output-type", "json"]);
89
+ try {
90
+ const cruise = JSON.parse(result.stdout);
91
+ const violations = cruise.summary.violations.map((v) => ({ ...v, from: fromRoot(rules.dir, v.from), to: fromRoot(rules.dir, v.to) }));
92
+ const ignored = ignoredBy(repo, violations.map((v) => v.from));
93
+ const findings = summarize(violations.filter((v) => !ignored.has(v.from)));
94
+ return unreadTypeScript(repo, rules, cruise.modules) ? [...findings, ...typeScriptWarning(rules)] : findings;
95
+ }
96
+ catch {
97
+ const why = (result.error?.message ?? result.stderr).trim().split("\n").slice(-5).join(" ");
98
+ return finding(`dependency-cruiser failed: ${why}`, fromRoot(rules.dir, rules.file));
99
+ }
100
+ });
101
+ },
102
+ };
103
+ // The graph level is the repo's choice, kept where dependency-cruiser keeps it for its own high-level view.
104
+ function collapsePattern(dir, file) {
105
+ try {
106
+ const config = createRequire(join(dir, "noop.js"))(join(dir, file));
107
+ return config.options?.reporterOptions?.archi?.collapsePattern ?? DEFAULT_COLLAPSE;
108
+ }
109
+ catch {
110
+ return DEFAULT_COLLAPSE;
111
+ }
112
+ }
113
+ function graphOf(repo, bin, rules) {
114
+ const collapse = collapsePattern(join(repo, rules.dir), rules.file);
115
+ // The graph shows the repo's own parts; npm packages would bury them (the rules still check npm imports).
116
+ const ownCode = "^(?!(\\.\\./)*node_modules/)";
117
+ const result = depcruise(repo, bin, rules, ["--output-type", "mermaid", "--collapse", collapse, "--include-only", ownCode]);
118
+ if (result.status !== 0 || !result.stdout.trim())
119
+ return undefined;
120
+ if (result.stdout.length > MERMAID_MAX_CHARS) {
121
+ return "flowchart LR\n too_big[\"Graph too large to draw — set a coarser reporterOptions.archi.collapsePattern\"]";
122
+ }
123
+ return result.stdout.trim();
124
+ }
125
+ /** The repo's architecture as markdown — one mermaid graph per rules file — or undefined when there is nothing to draw. */
126
+ export function architectureGraph(repo) {
127
+ const bin = depcruiseBin(repo);
128
+ if (!bin)
129
+ return undefined;
130
+ const sections = findRules(repo).flatMap((rules) => {
131
+ const graph = graphOf(repo, bin, rules);
132
+ return graph ? [`## ${rules.dir === "." ? "Whole repo" : rules.dir}\n\n\`\`\`mermaid\n${graph}\n\`\`\``] : [];
133
+ });
134
+ return sections.length > 0 ? sections.join("\n\n") : undefined;
135
+ }
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const canonicalValues: Check;
@@ -0,0 +1,48 @@
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
+ const PRICE = /(?:[$₹€£]\s?\d[\d,]*(?:\.\d+)?)/g;
6
+ const CODE_BLOCK = /```[\s\S]*?```/g;
7
+ // An amount counts as a price only in a paragraph that talks about selling something;
8
+ // "Ravi paid ₹6,200" in a bill-splitting app is an example, not a price.
9
+ const PRICE_WORDS = /pric|cost|paywall|plan|tier|subscri|bundle|pack|purchase|buy|unlock|\/mo|per month|per year|annual|yearly|monthly/i;
10
+ function amounts(text) {
11
+ return (text.match(PRICE) ?? []).map((price) => price.replace(/\s/g, ""));
12
+ }
13
+ // A decision's `Rejected:` line names old prices on purpose; they are history, not a second home.
14
+ const REJECTED = /^\*\*Rejected:\*\*/;
15
+ function pricesIn(text) {
16
+ const paragraphs = text.replace(CODE_BLOCK, "").split(/\n\s*\n/).filter((paragraph) => !REJECTED.test(paragraph.trim()));
17
+ return [...new Set(paragraphs.filter((paragraph) => PRICE_WORDS.test(paragraph)).flatMap(amounts))];
18
+ }
19
+ function heldAmounts(text) {
20
+ return amounts(text.replace(CODE_BLOCK, ""));
21
+ }
22
+ const folderOf = (file) => file.split("/")[2];
23
+ export const canonicalValues = {
24
+ id: "canonical-values",
25
+ module: "docs",
26
+ description: "a price stated in a decision or screen note is held by an entity page — one home per fact (C-02)",
27
+ run({ repo }) {
28
+ const pages = wikiPages(repo).map((file) => ({ file, ...parseFrontmatter(readFileSync(join(repo, file), "utf8")) }));
29
+ const held = new Set(pages.filter((page) => folderOf(page.file) === "entities").flatMap((page) => heldAmounts(page.body)));
30
+ // Decisions and screen notes are where prices get restated. Concepts legitimately carry
31
+ // example amounts (a tax product explains slabs with them).
32
+ return pages
33
+ .filter((page) => ["decisions", "surfaces"].includes(folderOf(page.file)))
34
+ .flatMap((page) => {
35
+ const orphans = pricesIn(page.body).filter((price) => !held.has(price));
36
+ if (orphans.length === 0)
37
+ return [];
38
+ return [
39
+ {
40
+ check: "canonical-values",
41
+ level: "warn",
42
+ file: page.file,
43
+ message: `${orphans.join(", ")} stated here but held by no entity page`,
44
+ },
45
+ ];
46
+ });
47
+ },
48
+ };
@@ -0,0 +1,7 @@
1
+ import type { Check } from "./types.ts";
2
+ /**
3
+ * Runs each checker a repo or its estate declares under `commands:` in
4
+ * canon.yaml. canon does not know what they check; a non-zero exit is an error
5
+ * reported under the command's own id.
6
+ */
7
+ export declare const commandChecks: Check;
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { kindCovers } from "../config/schema.js";
5
+ const MAX_OUTPUT_LINES = 15;
6
+ function tail(text) {
7
+ return text.trim().split("\n").slice(-MAX_OUTPUT_LINES).join("\n");
8
+ }
9
+ /**
10
+ * Runs each checker a repo or its estate declares under `commands:` in
11
+ * canon.yaml. canon does not know what they check; a non-zero exit is an error
12
+ * reported under the command's own id.
13
+ */
14
+ export const commandChecks = {
15
+ id: "commands",
16
+ module: null,
17
+ description: "every checker declared under `commands:` in canon.yaml passes",
18
+ run({ repo, config }) {
19
+ return config.commands
20
+ .filter((command) => !command.if || existsSync(join(repo, command.if)))
21
+ .filter((command) => !command.module || config.modules.includes(command.module))
22
+ .filter((command) => !command.kinds || command.kinds.some((kind) => kindCovers(config.kind, kind)))
23
+ .flatMap((command) => {
24
+ const result = spawnSync("sh", ["-c", command.run], { cwd: repo, encoding: "utf8" });
25
+ if (result.error) {
26
+ return [{ check: command.id, level: "error", file: command.if, message: `could not run: ${result.error.message}` }];
27
+ }
28
+ if (result.status === 0)
29
+ return [];
30
+ return [{ check: command.id, level: "error", file: command.if, message: tail(`${result.stdout}\n${result.stderr}`) }];
31
+ });
32
+ },
33
+ };
@@ -0,0 +1,5 @@
1
+ import { type Module } from "../config/schema.ts";
2
+ import type { Check } from "./types.ts";
3
+ /** What .context must hold: README.md and tasks.md always, the knowledge folders only when docs or knowledge is on. */
4
+ export declare function requiredContext(modules: Module[]): string[];
5
+ export declare const contextLayout: Check;
@@ -0,0 +1,83 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { CONTEXT_FOLDERS } from "../config/schema.js";
5
+ const KNOWLEDGE_CONTEXT = ["raw", "wiki", "features"];
6
+ /** What .context must hold: README.md and tasks.md always, the knowledge folders only when docs or knowledge is on. */
7
+ export function requiredContext(modules) {
8
+ const knowledge = modules.includes("docs") || modules.includes("knowledge");
9
+ return ["README.md", "tasks.md", ...(knowledge ? KNOWLEDGE_CONTEXT : [])];
10
+ }
11
+ const ROOT_FILES = ["README.md", "tasks.md"];
12
+ const LIVE_FOLDERS = ["marketing", "metrics", "support", "legal"];
13
+ const ROOT_STRAYS = ["TODO.md", "STATUS.md", "SETUP.md"];
14
+ function emptyFolders(dir) {
15
+ const entries = readdirSync(dir, { withFileTypes: true });
16
+ if (entries.length === 0)
17
+ return [dir];
18
+ return entries.filter((entry) => entry.isDirectory()).flatMap((entry) => emptyFolders(join(dir, entry.name)));
19
+ }
20
+ function ignored(repo, path) {
21
+ return spawnSync("git", ["-C", repo, "check-ignore", "-q", path]).status === 0;
22
+ }
23
+ export const contextLayout = {
24
+ id: "context-layout",
25
+ module: null,
26
+ description: ".context/ has the required folders, only the allowed ones (the ten plus context.folders), and only README.md + tasks.md at its root",
27
+ run({ repo, config }) {
28
+ const context = join(repo, ".context");
29
+ const finding = (level, file, message) => ({
30
+ check: "context-layout",
31
+ level,
32
+ file,
33
+ message,
34
+ });
35
+ if (!existsSync(context))
36
+ return [finding("error", ".context", "no .context/ folder — run `canon init`")];
37
+ const findings = requiredContext(config.modules).filter((name) => !existsSync(join(context, name))).map((name) => finding("error", `.context/${name}`, `required ${name} is missing`));
38
+ const allowed = [...CONTEXT_FOLDERS, ...config.context.folders.map((entry) => entry.folder)];
39
+ for (const entry of readdirSync(context, { withFileTypes: true })) {
40
+ if (entry.name === ".DS_Store")
41
+ continue;
42
+ if (entry.isDirectory() && !allowed.includes(entry.name)) {
43
+ findings.push(finding("error", `.context/${entry.name}`, `${entry.name}/ is not an allowed folder — move it, or add it under context.folders in canon.yaml`));
44
+ }
45
+ if (entry.isFile() && !ROOT_FILES.includes(entry.name)) {
46
+ findings.push(finding("error", `.context/${entry.name}`, "only README.md and tasks.md may sit at .context/ root"));
47
+ }
48
+ }
49
+ if (existsSync(join(context, "raw")) && !existsSync(join(context, "raw", ".gitkeep"))) {
50
+ findings.push(finding("warn", ".context/raw/.gitkeep", "raw/ needs its .gitkeep so it survives a clean clone"));
51
+ }
52
+ // git drops an empty folder on clone, so on disk it promises something that is not there.
53
+ // A gitignored one is tool output that recreates itself.
54
+ for (const empty of emptyFolders(context)) {
55
+ if (empty !== join(context, "raw") && !ignored(repo, empty)) {
56
+ findings.push(finding("error", relative(repo, empty), "empty folder — git will drop it"));
57
+ }
58
+ }
59
+ // Once something is live, the go-to-market folders are owed. release.yml is the stage signal.
60
+ const release = join(context, "ops", "release.yml");
61
+ if (existsSync(release) && /^\s+live:(?!\s*null)/m.test(readFileSync(release, "utf8"))) {
62
+ for (const owed of LIVE_FOLDERS) {
63
+ if (!existsSync(join(context, owed)))
64
+ findings.push(finding("error", `.context/${owed}`, `live (ops/release.yml) but no ${owed}/`));
65
+ }
66
+ }
67
+ for (const stray of ROOT_STRAYS) {
68
+ if (existsSync(join(repo, stray)))
69
+ findings.push(finding("error", stray, "nothing renders it at the root — it belongs in .context/ (tasks.md, ops/)"));
70
+ }
71
+ if (existsSync(join(repo, "docs")))
72
+ findings.push(finding("error", "docs/", "a root docs/ folder — that material belongs in .context/"));
73
+ if (existsSync(join(repo, "store.config.json")) && existsSync(join(context, "listing", "ios"))) {
74
+ findings.push(finding("error", "store.config.json", "two Apple listings — .context/listing/ios/ is the one source"));
75
+ }
76
+ // Without the pointer the whole .context/ tree is invisible to an agent.
77
+ const agents = join(repo, "AGENTS.md");
78
+ if (existsSync(agents) && !readFileSync(agents, "utf8").includes("tasks.md")) {
79
+ findings.push(finding("error", "AGENTS.md", "does not point at .context/tasks.md"));
80
+ }
81
+ return findings;
82
+ },
83
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from "./types.ts";
2
+ export declare const contextSize: Check;