@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.
- package/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +5 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/cli/dist/api.d.ts +3 -0
- package/cli/dist/api.js +4 -0
- package/cli/dist/checks/architecture.d.ts +23 -0
- package/cli/dist/checks/architecture.js +135 -0
- package/cli/dist/checks/canonical-values.d.ts +2 -0
- package/cli/dist/checks/canonical-values.js +48 -0
- package/cli/dist/checks/context-layout.d.ts +5 -0
- package/cli/dist/checks/context-layout.js +83 -0
- package/cli/dist/checks/context-size.d.ts +2 -0
- package/cli/dist/checks/context-size.js +46 -0
- package/cli/dist/checks/copies.d.ts +3 -0
- package/cli/dist/checks/copies.js +73 -0
- package/cli/dist/checks/external.d.ts +7 -0
- package/cli/dist/checks/external.js +31 -0
- package/cli/dist/checks/features.d.ts +2 -0
- package/cli/dist/checks/features.js +38 -0
- package/cli/dist/checks/index.d.ts +2 -0
- package/cli/dist/checks/index.js +33 -0
- package/cli/dist/checks/listing.d.ts +2 -0
- package/cli/dist/checks/listing.js +51 -0
- package/cli/dist/checks/metrics.d.ts +2 -0
- package/cli/dist/checks/metrics.js +14 -0
- package/cli/dist/checks/pipeline.d.ts +8 -0
- package/cli/dist/checks/pipeline.js +34 -0
- package/cli/dist/checks/refs.d.ts +2 -0
- package/cli/dist/checks/refs.js +92 -0
- package/cli/dist/checks/shared-files.d.ts +22 -0
- package/cli/dist/checks/shared-files.js +84 -0
- package/cli/dist/checks/sources.d.ts +2 -0
- package/cli/dist/checks/sources.js +40 -0
- package/cli/dist/checks/tasks.d.ts +2 -0
- package/cli/dist/checks/tasks.js +66 -0
- package/cli/dist/checks/types.d.ts +23 -0
- package/cli/dist/checks/types.js +1 -0
- package/cli/dist/checks/wiki-frontmatter.d.ts +3 -0
- package/cli/dist/checks/wiki-frontmatter.js +84 -0
- package/cli/dist/commands/add.d.ts +3 -0
- package/cli/dist/commands/add.js +24 -0
- package/cli/dist/commands/approve.d.ts +2 -0
- package/cli/dist/commands/approve.js +20 -0
- package/cli/dist/commands/check.d.ts +3 -0
- package/cli/dist/commands/check.js +19 -0
- package/cli/dist/commands/copies.d.ts +3 -0
- package/cli/dist/commands/copies.js +23 -0
- package/cli/dist/commands/doctor.d.ts +2 -0
- package/cli/dist/commands/doctor.js +16 -0
- package/cli/dist/commands/env.d.ts +7 -0
- package/cli/dist/commands/env.js +6 -0
- package/cli/dist/commands/hooks.d.ts +6 -0
- package/cli/dist/commands/hooks.js +55 -0
- package/cli/dist/commands/init.d.ts +3 -0
- package/cli/dist/commands/init.js +78 -0
- package/cli/dist/commands/learn.d.ts +3 -0
- package/cli/dist/commands/learn.js +36 -0
- package/cli/dist/commands/pipeline.d.ts +3 -0
- package/cli/dist/commands/pipeline.js +26 -0
- package/cli/dist/commands/standards.d.ts +3 -0
- package/cli/dist/commands/standards.js +26 -0
- package/cli/dist/commands/status.d.ts +3 -0
- package/cli/dist/commands/status.js +11 -0
- package/cli/dist/commands/sync.d.ts +3 -0
- package/cli/dist/commands/sync.js +23 -0
- package/cli/dist/commands/ui.d.ts +3 -0
- package/cli/dist/commands/ui.js +64 -0
- package/cli/dist/commands/weigh.d.ts +2 -0
- package/cli/dist/commands/weigh.js +16 -0
- package/cli/dist/config/load.d.ts +23 -0
- package/cli/dist/config/load.js +104 -0
- package/cli/dist/config/schema.d.ts +181 -0
- package/cli/dist/config/schema.js +106 -0
- package/cli/dist/doctor.d.ts +12 -0
- package/cli/dist/doctor.js +31 -0
- package/cli/dist/format.d.ts +6 -0
- package/cli/dist/format.js +35 -0
- package/cli/dist/index.d.ts +2 -0
- package/cli/dist/index.js +48 -0
- package/cli/dist/init.d.ts +46 -0
- package/cli/dist/init.js +119 -0
- package/cli/dist/learn.d.ts +44 -0
- package/cli/dist/learn.js +80 -0
- package/cli/dist/lib/files.d.ts +3 -0
- package/cli/dist/lib/files.js +23 -0
- package/cli/dist/lib/frontmatter.d.ts +6 -0
- package/cli/dist/lib/frontmatter.js +16 -0
- package/cli/dist/lib/system.d.ts +4 -0
- package/cli/dist/lib/system.js +19 -0
- package/cli/dist/packs.d.ts +38 -0
- package/cli/dist/packs.js +85 -0
- package/cli/dist/pipeline.d.ts +20 -0
- package/cli/dist/pipeline.js +30 -0
- package/cli/dist/run.d.ts +19 -0
- package/cli/dist/run.js +40 -0
- package/cli/dist/search/mcp.d.ts +2 -0
- package/cli/dist/search/mcp.js +11 -0
- package/cli/dist/search/qmd.d.ts +9 -0
- package/cli/dist/search/qmd.js +13 -0
- package/cli/dist/search/search.d.ts +2 -0
- package/cli/dist/search/search.js +13 -0
- package/cli/dist/standards.d.ts +14 -0
- package/cli/dist/standards.js +42 -0
- package/cli/dist/status.d.ts +3 -0
- package/cli/dist/status.js +26 -0
- package/cli/dist/sync.d.ts +19 -0
- package/cli/dist/sync.js +30 -0
- package/cli/dist/ui.d.ts +15 -0
- package/cli/dist/ui.js +69 -0
- package/package.json +55 -4
- package/packs/expo/pack.yaml +5 -0
- package/packs/firebase/pack.yaml +5 -0
- package/packs/nextjs/pack.yaml +5 -0
- package/packs/react/pack.yaml +5 -0
- package/packs/revenuecat/pack.yaml +5 -0
- package/packs/uniwind/pack.yaml +5 -0
- package/scripts/log-verdict.mjs +56 -0
- package/skills/ask/SKILL.md +40 -0
- package/skills/decision/SKILL.md +36 -0
- package/skills/ingest/SKILL.md +45 -0
- package/skills/standards/SKILL.md +16 -0
- package/skills/weigh/SKILL.md +160 -0
- package/skills/wiki-lint/SKILL.md +67 -0
- package/standards/README.md +14 -0
- package/standards/agent-contract.md +47 -0
- package/standards/constitution.md +104 -0
- package/standards/context-layout.md +129 -0
- package/standards/dev-methods.md +29 -0
- package/standards/wiki-schema.md +71 -0
- package/templates/dependency-cruiser/app.cjs +23 -0
- package/templates/dependency-cruiser/base.cjs +52 -0
- package/templates/dependency-cruiser/package.cjs +31 -0
- package/templates/dependency-cruiser/root.cjs +15 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "canon",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Ritesh Firodiya"
|
|
5
|
+
},
|
|
6
|
+
"plugins": [
|
|
7
|
+
{
|
|
8
|
+
"name": "canon",
|
|
9
|
+
"source": "./",
|
|
10
|
+
"version": "0.15.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
|
+
}
|
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,147 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/assets/icon.svg" width="96" height="96" alt="canon icon">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
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
|
+
copies: # files products copy from one source repo, in named sets
|
|
104
|
+
source: design-system
|
|
105
|
+
sets:
|
|
106
|
+
upload: [scripts/upload.mjs]
|
|
107
|
+
shell-web:
|
|
108
|
+
- { from: designs/mobile/index.html, to: designs/web/index.html, named: true }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```yaml
|
|
112
|
+
# <repo>/.claude/canon.yaml — written by `canon init`
|
|
113
|
+
canon: 0.14.0
|
|
114
|
+
kind: expo-app # expo-app | web | content
|
|
115
|
+
modules: [docs, knowledge, code, listing, refs]
|
|
116
|
+
packs: [expo]
|
|
117
|
+
name: Acme # how the product is written; `named` copies swap it in
|
|
118
|
+
copies:
|
|
119
|
+
use: [upload, shell-web] # the sets this repo uses; only these are held to the source
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Scalars: the most specific layer wins. `packs`, `standards` and `copies.use`
|
|
123
|
+
add up across layers; `commands`, `doctor.tools`, `wiki.types` and
|
|
124
|
+
`context.folders` merge by name.
|
|
125
|
+
|
|
126
|
+
## Layout
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
.claude-plugin/ plugin manifest and marketplace
|
|
130
|
+
skills/ weigh, standards, ask, ingest, decision, wiki-lint
|
|
131
|
+
agents/ challenger — a second opinion for too-large requests
|
|
132
|
+
hooks/ session start: constitution titles, qmd refresh
|
|
133
|
+
standards/ default standards
|
|
134
|
+
packs/ vendor doc packs
|
|
135
|
+
templates/ files init writes (dependency-cruiser base and starters)
|
|
136
|
+
scripts/ log-verdict.mjs — the verdict log
|
|
137
|
+
evals/ plugin evals and the fixture product
|
|
138
|
+
cli/ the canon CLI (TypeScript, vitest); `@freetesh/canon/api` for tools built on it
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Develop
|
|
142
|
+
|
|
143
|
+
`pnpm check` runs `canon check` on this repo, the typecheck and every test.
|
|
144
|
+
The version lives in `package.json` and `.claude-plugin/plugin.json`; bump both
|
|
145
|
+
with a CHANGELOG entry. A push to main with a new version publishes to npm.
|
|
146
|
+
|
|
147
|
+
MIT licence.
|
package/cli/dist/api.js
ADDED
|
@@ -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,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,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,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,73 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } 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
|
+
// Fraction of a shared standard's lines that must appear in a product file
|
|
7
|
+
// before it counts as a copy rather than a reference.
|
|
8
|
+
const COPY_THRESHOLD = 0.6;
|
|
9
|
+
const STANDARD_COPIES = [{ file: ".context/wiki/CLAUDE.md", standard: "standards/wiki-schema.md" }];
|
|
10
|
+
export const staleCopies = {
|
|
11
|
+
id: "stale-copies",
|
|
12
|
+
module: null,
|
|
13
|
+
description: "no instruction files for agents that are not used (Copilot, Cursor, Windsurf)",
|
|
14
|
+
run({ repo }) {
|
|
15
|
+
return OTHER_AGENT_FILES.filter((file) => existsSync(join(repo, file))).map((file) => ({
|
|
16
|
+
check: "stale-copies",
|
|
17
|
+
level: "warn",
|
|
18
|
+
file,
|
|
19
|
+
message: "instructions for an agent not in use — delete, Claude reads AGENTS.md",
|
|
20
|
+
}));
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
function meaningfulLines(text) {
|
|
24
|
+
return new Set(text
|
|
25
|
+
.split("\n")
|
|
26
|
+
.map((line) => line.trim())
|
|
27
|
+
.filter((line) => line.length > 0 && !line.startsWith("<!--")));
|
|
28
|
+
}
|
|
29
|
+
function sharedFraction(standard, candidate) {
|
|
30
|
+
const wanted = meaningfulLines(standard);
|
|
31
|
+
const present = meaningfulLines(candidate);
|
|
32
|
+
const shared = [...wanted].filter((line) => present.has(line)).length;
|
|
33
|
+
return wanted.size === 0 ? 0 : shared / wanted.size;
|
|
34
|
+
}
|
|
35
|
+
export const standardsCopies = {
|
|
36
|
+
id: "standards-copies",
|
|
37
|
+
module: null,
|
|
38
|
+
description: "shared rules and skills come from canon, not from a copy in the product",
|
|
39
|
+
run({ repo, canonRoot }) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
for (const { file, standard } of STANDARD_COPIES) {
|
|
42
|
+
const productFile = join(repo, file);
|
|
43
|
+
const standardFile = join(canonRoot, standard);
|
|
44
|
+
if (!existsSync(productFile) || !existsSync(standardFile))
|
|
45
|
+
continue;
|
|
46
|
+
const fraction = sharedFraction(readFileSync(standardFile, "utf8"), readFileSync(productFile, "utf8"));
|
|
47
|
+
if (fraction >= COPY_THRESHOLD) {
|
|
48
|
+
findings.push({
|
|
49
|
+
check: "standards-copies",
|
|
50
|
+
level: "warn",
|
|
51
|
+
file,
|
|
52
|
+
message: `${Math.round(fraction * 100)}% copy of canon ${standard} — replace with a pointer`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const canonSkills = join(canonRoot, "skills");
|
|
57
|
+
const productSkills = join(repo, ".claude", "skills");
|
|
58
|
+
if (existsSync(canonSkills) && existsSync(productSkills)) {
|
|
59
|
+
const provided = new Set(readdirSync(canonSkills));
|
|
60
|
+
for (const skill of readdirSync(productSkills)) {
|
|
61
|
+
if (!provided.has(skill))
|
|
62
|
+
continue;
|
|
63
|
+
findings.push({
|
|
64
|
+
check: "standards-copies",
|
|
65
|
+
level: "warn",
|
|
66
|
+
file: `.claude/skills/${skill}`,
|
|
67
|
+
message: `canon provides this as canon:${skill} — delete the product copy`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return findings;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -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;
|