@freetesh/canon 0.16.0 → 0.17.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.
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "name": "canon",
9
9
  "source": "./",
10
- "version": "0.16.0",
10
+ "version": "0.17.0",
11
11
  "description": "Keeps your domain knowledge and AI rules in your repos and enforces them, so Claude hallucinates less."
12
12
  }
13
13
  ],
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "canon",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Keeps your domain knowledge and AI rules in your repos and enforces them, so Claude hallucinates less."
5
5
  }
package/README.md CHANGED
@@ -47,9 +47,11 @@ is a separate paid product built on this CLI.
47
47
  listing tree, dead `.context/` paths, prices with no canonical home, shared
48
48
  files, the import architecture, pipeline gates, and every checker your config
49
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.
50
+ - **Standards with one home.** canon ships defaults every repo follows, and a
51
+ library a repo imports by name (`standards: [design-sets, pipeline]`): design
52
+ sets, the build pipeline, Expo, frontend, performance, secrets, store listings.
53
+ A repo's own rules can sit in a folder it adds. `canon standards` shows what
54
+ applies.
53
55
  - **A repo names only what it uses.** Modules, packs and shared-file sets are
54
56
  opt-in; there is no skip list and no setting that turns a check off.
55
57
 
@@ -97,9 +99,8 @@ repo whose `canon.yaml` says `estate: true`, or `CANON_ESTATE`.
97
99
  ```yaml
98
100
  # <estate>/.claude/canon.yaml — shared by every repo below it
99
101
  estate: true
100
- standards: [.canon/standards] # estate rules; same name replaces a canon default
101
102
  commands: # your own checkers, run by `canon check`
102
- - { id: designs, run: ./scripts/check-designs.sh, if: .context/designs, module: design }
103
+ - { id: lint-copy, run: ./scripts/lint-copy.sh, if: .context/designs, module: design }
103
104
  - { id: designs-first, run: test -d .context/designs, kinds: [mobile, web] } # only repos that ship screens
104
105
  copies: # files products copy from one source repo, in named sets
105
106
  source: design-system
@@ -111,8 +112,9 @@ copies: # files products copy from one source repo,
111
112
 
112
113
  ```yaml
113
114
  # <repo>/.claude/canon.yaml — written by `canon init`
114
- canon: 0.16.0
115
+ canon: 0.17.0
115
116
  kind: mobile # mobile | web | mobile-and-web | package | content
117
+ standards: [design-sets, pipeline, expo] # canon's library standards this repo follows; ./rules adds a folder of its own
116
118
  modules: [docs, knowledge, code, listing, refs]
117
119
  packs: [expo]
118
120
  name: Acme # how the product is written; `named` copies swap it in
@@ -0,0 +1,5 @@
1
+ import type { Check } from "./types.ts";
2
+ /** Every drawn surface: a folder under .context/designs/ with a routes.js manifest. */
3
+ export declare function designSurfaces(repo: string): string[];
4
+ /** Runs canon's design-set checker (scripts/check-designs.py) once per drawn surface. */
5
+ export declare const designs: Check;
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ const DESIGNS = ".context/designs";
5
+ const MAX_OUTPUT_LINES = 15;
6
+ /** Every drawn surface: a folder under .context/designs/ with a routes.js manifest. */
7
+ export function designSurfaces(repo) {
8
+ const root = join(repo, DESIGNS);
9
+ if (!existsSync(root))
10
+ return [];
11
+ return readdirSync(root, { withFileTypes: true })
12
+ .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "routes.js")))
13
+ .map((entry) => `${DESIGNS}/${entry.name}`)
14
+ .sort();
15
+ }
16
+ /** Runs canon's design-set checker (scripts/check-designs.py) once per drawn surface. */
17
+ export const designs = {
18
+ id: "designs",
19
+ module: "design",
20
+ description: "every drawn surface follows the design-sets standard (scripts/check-designs.py)",
21
+ run({ repo, canonRoot }) {
22
+ const script = join(canonRoot, "scripts", "check-designs.py");
23
+ return designSurfaces(repo).flatMap((surface) => {
24
+ const result = spawnSync("python3", [script, surface], { cwd: repo, encoding: "utf8" });
25
+ if (result.error)
26
+ return [{ check: "designs", level: "error", file: surface, message: `could not run python3: ${result.error.message}` }];
27
+ if (result.status === 0)
28
+ return [];
29
+ const output = `${result.stdout}\n${result.stderr}`.trim().split("\n").slice(-MAX_OUTPUT_LINES).join("\n");
30
+ return [{ check: "designs", level: "error", file: surface, message: output }];
31
+ });
32
+ },
33
+ };
@@ -5,6 +5,7 @@ import { contextSize } from "./context-size.js";
5
5
  import { staleCopies } from "./stale-copies.js";
6
6
  import { standardsCopies } from "./standards-copies.js";
7
7
  import { commandChecks } from "./commands.js";
8
+ import { designs } from "./designs.js";
8
9
  import { features } from "./features.js";
9
10
  import { listing } from "./listing.js";
10
11
  import { metricsDefinitions } from "./metrics-definitions.js";
@@ -30,5 +31,6 @@ export const ALL_CHECKS = [
30
31
  metricsDefinitions,
31
32
  sources,
32
33
  pipeline,
34
+ designs,
33
35
  commandChecks,
34
36
  ];
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { parse } from "yaml";
4
- import { MODULES_BY_KIND, layerSchema } from "./schema.js";
4
+ import { isStandardName, MODULES_BY_KIND, layerSchema } from "./schema.js";
5
5
  const EXPO_MARKERS = ["app.config.ts", "app.config.js", "app.json"];
6
6
  const WEB_MARKERS = ["next.config.ts", "next.config.js", "next.config.mjs", "vite.config.ts"];
7
7
  function configPath(folder) {
@@ -94,7 +94,7 @@ export function loadConfig(repo, options = {}) {
94
94
  modules: lastDefined(pick("modules")) ?? inferModules(repo, kind),
95
95
  expo: Object.assign({}, ...pick("expo")),
96
96
  packs: [...new Set(pick("packs").flatMap((packs) => packs ?? []))].sort(),
97
- standards: [...new Set(layers.flatMap((layer) => (layer.data.standards ?? []).map((dir) => resolve(layer.base, dir))))],
97
+ standards: [...new Set(layers.flatMap((layer) => (layer.data.standards ?? []).map((entry) => (isStandardName(entry) ? entry : resolve(layer.base, entry)))))],
98
98
  commands: mergeBy(pick("commands").map((list) => list ?? []), (command) => command.id),
99
99
  doctor: { tools: mergeBy(pick("doctor").map((doctor) => doctor?.tools ?? []), (tool) => tool.tool) },
100
100
  weigh: { weights: Object.assign({}, ...pick("weigh").map((weigh) => weigh?.weights)) },
@@ -11,6 +11,8 @@ export declare const WIKI_FOLDER_BY_TYPE: Record<string, string>;
11
11
  export declare const REMOVED_WIKI_FOLDERS: Record<string, string>;
12
12
  /** The `.context/` folders every repo may have. A repo adds more under `context.folders`. */
13
13
  export declare const CONTEXT_FOLDERS: string[];
14
+ /** A `standards:` entry naming a library standard rather than a folder path. */
15
+ export declare function isStandardName(entry: string): boolean;
14
16
  export type Kind = (typeof KINDS)[number];
15
17
  export type Module = (typeof MODULES)[number];
16
18
  declare const command: z.ZodObject<{
@@ -167,7 +169,7 @@ export interface Config {
167
169
  sdk?: number;
168
170
  };
169
171
  packs: string[];
170
- /** Absolute standards folders, estate first; a later file of the same name wins. */
172
+ /** Library standard names and absolute folders, estate first; a later file of the same name wins. */
171
173
  standards: string[];
172
174
  commands: CommandSpec[];
173
175
  doctor: {
@@ -19,6 +19,10 @@ export const REMOVED_WIKI_FOLDERS = {
19
19
  };
20
20
  /** The `.context/` folders every repo may have. A repo adds more under `context.folders`. */
21
21
  export const CONTEXT_FOLDERS = ["raw", "wiki", "features", "designs", "listing", "marketing", "metrics", "support", "legal", "ops"];
22
+ /** A `standards:` entry naming a library standard rather than a folder path. */
23
+ export function isStandardName(entry) {
24
+ return /^[a-z0-9-]+$/.test(entry);
25
+ }
22
26
  const principleId = z.string().regex(/^C-\d{2}$/, "principle IDs look like C-07");
23
27
  const command = z
24
28
  .object({
@@ -83,7 +87,7 @@ export const layerSchema = z
83
87
  modules: z.array(z.enum(MODULES)),
84
88
  expo: z.object({ sdk: z.number().int().positive() }).partial(),
85
89
  packs: z.array(z.string().regex(/^[a-z0-9-]+$/, "pack names are lowercase-kebab")),
86
- /** Folders of extra standards, relative to the folder that holds `.claude/`. */
90
+ /** Standards to add: a bare name imports canon's library standard (`design-sets`); a path (`./rules`) is a folder of the repo's own. */
87
91
  standards: z.array(z.string()),
88
92
  commands: z.array(command),
89
93
  doctor: z.object({ tools: z.array(tool) }).partial(),
@@ -4,11 +4,16 @@ export interface Standard {
4
4
  /** "canon" for a built-in default, else the configured folder it came from. */
5
5
  from: string;
6
6
  }
7
- /** canon's default standards, then each configured folder in order; a later file of the same name replaces an earlier one. */
8
- export declare function resolveStandards(canonRoot: string, folders: string[]): Standard[];
7
+ /** The opt-in standards canon ships; a repo imports one by name under `standards:`. */
8
+ export declare function libraryStandards(canonRoot: string): string[];
9
9
  /**
10
- * Principle headings from canon's constitution and every configured one. A
11
- * constitution in a folder extends rather than replaces: it adds principles,
12
- * and restating an ID rewords it.
10
+ * canon's defaults, then each `standards:` entry in order: a name imports canon's library
11
+ * standard, a folder adds its files. A later file of the same name replaces an earlier one.
13
12
  */
14
- export declare function constitutionTitles(canonRoot: string, folders: string[]): string[];
13
+ export declare function resolveStandards(canonRoot: string, entries: string[]): Standard[];
14
+ /**
15
+ * Principle headings from canon's constitution, every imported library standard
16
+ * and every configured folder's constitution. They extend rather than replace:
17
+ * each adds principles, and restating an ID rewords it.
18
+ */
19
+ export declare function constitutionTitles(canonRoot: string, entries: string[]): string[];
@@ -1,35 +1,55 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { isStandardName } from "./config/schema.js";
3
4
  const NOT_STANDARDS = new Set(["README.md"]);
4
5
  const PRINCIPLE = /^### (C-\d{2}) (.+)$/;
5
6
  function markdownIn(dir) {
6
7
  return readdirSync(dir).filter((file) => file.endsWith(".md") && !NOT_STANDARDS.has(file));
7
8
  }
8
- /** canon's default standards, then each configured folder in order; a later file of the same name replaces an earlier one. */
9
- export function resolveStandards(canonRoot, folders) {
9
+ /** The opt-in standards canon ships; a repo imports one by name under `standards:`. */
10
+ export function libraryStandards(canonRoot) {
11
+ const dir = join(canonRoot, "standards", "library");
12
+ return existsSync(dir) ? markdownIn(dir).map((file) => file.replace(/\.md$/, "")).sort() : [];
13
+ }
14
+ function libraryFile(canonRoot, name) {
15
+ const path = join(canonRoot, "standards", "library", `${name}.md`);
16
+ if (!existsSync(path))
17
+ throw new Error(`no canon standard named ${name} — canon has: ${libraryStandards(canonRoot).join(", ")}`);
18
+ return path;
19
+ }
20
+ /**
21
+ * canon's defaults, then each `standards:` entry in order: a name imports canon's library
22
+ * standard, a folder adds its files. A later file of the same name replaces an earlier one.
23
+ */
24
+ export function resolveStandards(canonRoot, entries) {
10
25
  const byName = new Map();
11
- const sources = [{ dir: join(canonRoot, "standards"), from: "canon" }, ...folders.map((dir) => ({ dir, from: dir }))];
12
- for (const { dir, from } of sources) {
13
- if (!existsSync(dir))
14
- throw new Error(`standards folder not found: ${dir}`);
15
- for (const file of markdownIn(dir)) {
16
- const name = file.replace(/\.md$/, "");
17
- // Constitutions add up (see constitutionTitles), so each one keeps its own entry.
18
- const key = name === "constitution" ? `${name}:${from}` : name;
19
- byName.set(key, { name, path: join(dir, file), from });
26
+ const add = (name, path, from) => {
27
+ // Constitutions add up (see constitutionTitles), so each one keeps its own entry.
28
+ byName.set(name === "constitution" ? `${name}:${from}` : name, { name, path, from });
29
+ };
30
+ const defaults = join(canonRoot, "standards");
31
+ for (const entry of [defaults, ...entries]) {
32
+ if (isStandardName(entry)) {
33
+ add(entry, libraryFile(canonRoot, entry), "canon library");
34
+ continue;
20
35
  }
36
+ if (!existsSync(entry))
37
+ throw new Error(`standards folder not found: ${entry}`);
38
+ const from = entry === defaults ? "canon" : entry;
39
+ for (const file of markdownIn(entry))
40
+ add(file.replace(/\.md$/, ""), join(entry, file), from);
21
41
  }
22
42
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name) || Number(a.from !== "canon") - Number(b.from !== "canon"));
23
43
  }
24
44
  /**
25
- * Principle headings from canon's constitution and every configured one. A
26
- * constitution in a folder extends rather than replaces: it adds principles,
27
- * and restating an ID rewords it.
45
+ * Principle headings from canon's constitution, every imported library standard
46
+ * and every configured folder's constitution. They extend rather than replace:
47
+ * each adds principles, and restating an ID rewords it.
28
48
  */
29
- export function constitutionTitles(canonRoot, folders) {
49
+ export function constitutionTitles(canonRoot, entries) {
30
50
  const titles = new Map();
31
- for (const dir of [join(canonRoot, "standards"), ...folders]) {
32
- const file = join(dir, "constitution.md");
51
+ const files = [join(canonRoot, "standards"), ...entries].map((entry) => isStandardName(entry) ? libraryFile(canonRoot, entry) : join(entry, "constitution.md"));
52
+ for (const file of files) {
33
53
  if (!existsSync(file))
34
54
  continue;
35
55
  for (const line of readFileSync(file, "utf8").split("\n")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freetesh/canon",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "canon CLI: keep your domain knowledge and AI rules in your repo, and enforce them.",
5
5
  "license": "MIT",
6
6
  "author": "Ritesh Firodiya",
@@ -22,6 +22,7 @@
22
22
  "packs",
23
23
  "templates",
24
24
  "scripts/log-verdict.mjs",
25
+ "scripts/check-designs.py",
25
26
  ".claude-plugin"
26
27
  ],
27
28
  "engines": {