@freetesh/zeno 0.10.0 → 0.12.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "zeno",
4
- "version": "0.10.0",
4
+ "version": "0.12.0",
5
5
  "description": "Keeps your domain knowledge and AI rules local and enforces them, so Claude hallucinates less.",
6
6
  "author": {
7
7
  "name": "Ritesh Firodiya",
package/README.md CHANGED
@@ -54,8 +54,19 @@ It has two halves that read the same config:
54
54
  `.context` layout, agent contract). A team adds or replaces them from its own
55
55
  folder; a repo can override again. `zeno standards` shows what applies.
56
56
  - **Hard rules are checks.** `zeno check` validates layout, wiki frontmatter,
57
- stale pages, prices with no canonical home, copied standards, and runs every
58
- checker your config declares.
57
+ stale pages, placeholder text, page and `tasks.md` size caps, blocked tasks,
58
+ feature pages, the store listing tree, dead `.context/` paths, prices with no
59
+ canonical home, files shared across products, and runs every checker your
60
+ config declares. `zeno hooks install` makes every push run it.
61
+ - **Context stays small.** A line belongs in `.context/` only if an agent would
62
+ get something wrong without it. Decisions are one file per topic, overwritten
63
+ when the choice changes; `tasks.md` holds open work only; history is git's.
64
+ - **Stages stop at their gates.** A `pipeline:` in `zeno.yaml` lists your
65
+ stages in order, with the paths that show each has started. A gated stage
66
+ needs a person to run `zeno approve <stage>` before any later stage starts;
67
+ `zeno check` fails otherwise. Approvals are dated in
68
+ `.context/raw/zeno/gates.jsonl`, so you can see where work waits. A product
69
+ replaces the estate's pipeline by declaring its own.
59
70
  - **Each repo's architecture is enforced.** With the `architecture` module, a
60
71
  repo keeps its own import rules in `.dependency-cruiser.cjs` files: one at the
61
72
  root, or one per workspace under `apps/` and `packages/` (each runs from its
@@ -114,6 +125,8 @@ zeno init -y --kind web --modules docs,code --packs nextjs # same, no question
114
125
  zeno add # list packs; `zeno add expo` adds one and indexes its docs (-g: whole estate)
115
126
  zeno check # this repo against the standards (exit 1 on any error)
116
127
  zeno check --all -q # every repo in the estate, only the ones with findings
128
+ zeno copies --sync # overwrite drifted shared files from copies.source (never adds one)
129
+ zeno hooks install # a pre-push hook that runs `zeno check` (--all: every product)
117
130
  zeno standards # which standards apply here, and where each comes from
118
131
  zeno sync # refresh pack docs + search index, move the pinned zeno version, then check
119
132
  zeno status # version, kind, modules, config layers, check summary
@@ -141,7 +154,7 @@ doctor:
141
154
  tools:
142
155
  - { tool: pnpm, why: product scripts, hint: npm i -g pnpm }
143
156
  wiki:
144
- types: # added to the six built-in page types
157
+ types: # added to the built-in folders (concepts, entities, decisions, surfaces, synthesis)
145
158
  - type: postmortem
146
159
  folder: postmortems
147
160
  reason: incidents need a timeline and a fix
@@ -149,11 +162,17 @@ wiki:
149
162
  context:
150
163
  folders: # added to the ten .context folders
151
164
  - { folder: research, reason: interview notes, kept apart from raw/ }
165
+ copies: # files every product copies from one source repo
166
+ source: charades # a folder beside the estate config
167
+ files:
168
+ - scripts/play-upload.mjs # same path; a product carries it or skips it
169
+ - { from: .context/designs/mobile/index.html, to: [.context/designs/mobile/index.html, .context/designs/web/index.html], named: true }
170
+ - { from: .context/designs/mobile/shared.css, to: [.context/designs/mobile/shared.css], until: "PALETTE — " }
152
171
  ```
153
172
 
154
173
  ```yaml
155
174
  # <repo>/.claude/zeno.yaml — written by `zeno init`
156
- zeno: 0.9.0
175
+ zeno: 0.11.0
157
176
  kind: expo-app # expo-app | web | content
158
177
  modules: [docs, knowledge, code, listing] # all optional; design is added when .context/designs exists, metrics only when asked
159
178
  packs: [expo]
@@ -161,6 +180,10 @@ checks:
161
180
  stale-copies: { enabled: false, reason: "Cursor is still used here" }
162
181
  weigh:
163
182
  weights: { C-05: 2 }
183
+ name: Chitragupt # how the product is written; `named` copies swap it in
184
+ copies:
185
+ skip: # shared files this product does without, or keeps its own of
186
+ - { file: scripts/release-android.mjs, reason: built on EAS from a tag }
164
187
  ```
165
188
 
166
189
  Scalars: the most specific layer wins. `packs` and `standards` add up across
@@ -173,8 +196,8 @@ carry a `reason:`, and `zeno check` lists every override.
173
196
  | zeno default | What it holds |
174
197
  |---|---|
175
198
  | `constitution.md` | Principles C-01–C-15 a request is weighed against. A team adds C-20+ in its own `constitution.md`. |
176
- | `wiki-schema.md` | Wiki page types, frontmatter, required sections, lint rules |
177
- | `context-layout.md` | The `.context/` ten-folder layout, `features/` and `tasks.md` |
199
+ | `wiki-schema.md` | What belongs in the wiki, its folders, decisions, frontmatter and size caps |
200
+ | `context-layout.md` | The `.context/` ten-folder layout, `features/` and `tasks.md` (open work only) |
178
201
  | `agent-contract.md` | What every agent does before, during and after work |
179
202
 
180
203
  ## Packs
@@ -29,6 +29,13 @@ export function findRules(repo) {
29
29
  });
30
30
  }
31
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
+ }
32
39
  // The repo's own pinned copy, never a global one, so every machine runs the same version.
33
40
  function depcruiseBin(repo) {
34
41
  const bin = join(repo, "node_modules", ".bin", "depcruise");
@@ -81,7 +88,9 @@ export const architecture = {
81
88
  const result = depcruise(repo, bin, rules, ["--output-type", "json"]);
82
89
  try {
83
90
  const cruise = JSON.parse(result.stdout);
84
- const findings = summarize(cruise.summary.violations.map((v) => ({ ...v, from: fromRoot(rules.dir, v.from), to: fromRoot(rules.dir, v.to) })));
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)));
85
94
  return unreadTypeScript(repo, rules, cruise.modules) ? [...findings, ...typeScriptWarning(rules)] : findings;
86
95
  }
87
96
  catch {
@@ -10,25 +10,27 @@ const PRICE_WORDS = /pric|cost|paywall|plan|tier|subscri|bundle|pack|purchase|bu
10
10
  function amounts(text) {
11
11
  return (text.match(PRICE) ?? []).map((price) => price.replace(/\s/g, ""));
12
12
  }
13
+ // A decision's `Rejected:` line names old prices on purpose; they are history, not a second home.
14
+ const REJECTED = /^\*\*Rejected:\*\*/;
13
15
  function pricesIn(text) {
14
- const paragraphs = text.replace(CODE_BLOCK, "").split(/\n\s*\n/);
16
+ const paragraphs = text.replace(CODE_BLOCK, "").split(/\n\s*\n/).filter((paragraph) => !REJECTED.test(paragraph.trim()));
15
17
  return [...new Set(paragraphs.filter((paragraph) => PRICE_WORDS.test(paragraph)).flatMap(amounts))];
16
18
  }
17
19
  function heldAmounts(text) {
18
20
  return amounts(text.replace(CODE_BLOCK, ""));
19
21
  }
22
+ const folderOf = (file) => file.split("/")[2];
20
23
  export const canonicalValues = {
21
24
  id: "canonical-values",
22
25
  module: "docs",
23
- description: "a price shown on a surface page is held by an entity page — one home per fact (C-04)",
26
+ description: "a price stated in a decision or screen note is held by an entity page — one home per fact (C-02)",
24
27
  run({ repo }) {
25
28
  const pages = wikiPages(repo).map((file) => ({ file, ...parseFrontmatter(readFileSync(join(repo, file), "utf8")) }));
26
- const live = pages.filter((page) => page.data?.status !== "superseded");
27
- const held = new Set(live.filter((page) => page.data?.type === "entity").flatMap((page) => heldAmounts(page.body)));
28
- // Surfaces are where store prices get restated. Concepts legitimately carry
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
29
31
  // example amounts (a tax product explains slabs with them).
30
- return live
31
- .filter((page) => page.data?.type === "surface")
32
+ return pages
33
+ .filter((page) => ["decisions", "surfaces"].includes(folderOf(page.file)))
32
34
  .flatMap((page) => {
33
35
  const orphans = pricesIn(page.body).filter((price) => !held.has(price));
34
36
  if (orphans.length === 0)
@@ -1,5 +1,6 @@
1
- import { existsSync, readdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
3
4
  import { CONTEXT_FOLDERS } from "../config/schema.js";
4
5
  const KNOWLEDGE_CONTEXT = ["raw", "wiki", "features"];
5
6
  /** What .context must hold: README.md and tasks.md always, the knowledge folders only when docs or knowledge is on. */
@@ -8,6 +9,17 @@ export function requiredContext(modules) {
8
9
  return ["README.md", "tasks.md", ...(knowledge ? KNOWLEDGE_CONTEXT : [])];
9
10
  }
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
+ }
11
23
  export const contextLayout = {
12
24
  id: "context-layout",
13
25
  module: null,
@@ -37,6 +49,35 @@ export const contextLayout = {
37
49
  if (existsSync(join(context, "raw")) && !existsSync(join(context, "raw", ".gitkeep"))) {
38
50
  findings.push(finding("warn", ".context/raw/.gitkeep", "raw/ needs its .gitkeep so it survives a clean clone"));
39
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
+ }
40
81
  return findings;
41
82
  },
42
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,38 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { listFiles } from "../lib/files.js";
4
+ import { parseFrontmatter } from "../lib/frontmatter.js";
5
+ const TYPES = ["feature", "experiment", "chore", "platform"];
6
+ const STATUSES = ["planned", "building", "shipped", "shelved", "superseded"];
7
+ // A chore or a platform change ships with nothing a user reads; a feature that never shipped keeps its target release.
8
+ const NEEDS_STORE_COPY = ["feature", "experiment"];
9
+ const NEVER_SHIPPED = ["shelved", "superseded"];
10
+ const STORE_COPY = /^## Store copy[ \t]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m;
11
+ export const features = {
12
+ id: "features",
13
+ module: "docs",
14
+ description: "each features/ page has id, type and status from their sets; a released feature has its Store copy blockquote (the What's New text)",
15
+ run({ repo }) {
16
+ const pages = listFiles(repo, ".context/features", (name) => /^\d{4}-.+\.md$/.test(name));
17
+ return pages.flatMap((file) => {
18
+ const findings = [];
19
+ const add = (message) => findings.push({ check: "features", level: "error", file, message });
20
+ const { data, body } = parseFrontmatter(readFileSync(join(repo, file), "utf8"));
21
+ if (!data)
22
+ return [{ check: "features", level: "error", file, message: `no frontmatter (${basename(file)})` }];
23
+ for (const field of ["id", "type", "status"])
24
+ if (!data[field])
25
+ add(`missing ${field}`);
26
+ if (data.type && !TYPES.includes(String(data.type)))
27
+ add(`type ${data.type} is not one of ${TYPES.join(", ")}`);
28
+ if (data.status && !STATUSES.includes(String(data.status)))
29
+ add(`status ${data.status} is not one of ${STATUSES.join(", ")}`);
30
+ const released = /\d+\.\d+\.\d+/.test(String(data.release ?? "")) && !NEVER_SHIPPED.includes(String(data.status));
31
+ const copy = STORE_COPY.exec(body.replace(/<!--[\s\S]*?-->/g, ""));
32
+ if (released && NEEDS_STORE_COPY.includes(String(data.type)) && !(copy && /^>[ \t]*\S/m.test(copy[1]))) {
33
+ add(`ships in ${data.release} with no "> …" line under ## Store copy`);
34
+ }
35
+ return findings;
36
+ });
37
+ },
38
+ };
@@ -1,19 +1,33 @@
1
1
  import { architecture } from "./architecture.js";
2
2
  import { canonicalValues } from "./canonical-values.js";
3
3
  import { contextLayout } from "./context-layout.js";
4
+ import { contextSize } from "./context-size.js";
4
5
  import { staleCopies, standardsCopies } from "./copies.js";
5
6
  import { commandChecks } from "./external.js";
7
+ import { features } from "./features.js";
8
+ import { listing } from "./listing.js";
6
9
  import { metricsDefinitions } from "./metrics.js";
10
+ import { pipeline } from "./pipeline.js";
11
+ import { refs } from "./refs.js";
12
+ import { sharedFiles } from "./shared-files.js";
7
13
  import { sources } from "./sources.js";
14
+ import { tasks } from "./tasks.js";
8
15
  import { wikiFrontmatter } from "./wiki-frontmatter.js";
9
16
  export const ALL_CHECKS = [
10
17
  contextLayout,
11
18
  wikiFrontmatter,
19
+ contextSize,
20
+ tasks,
21
+ features,
22
+ listing,
12
23
  canonicalValues,
13
24
  standardsCopies,
25
+ sharedFiles,
26
+ refs,
14
27
  staleCopies,
15
28
  architecture,
16
29
  metricsDefinitions,
17
30
  sources,
31
+ pipeline,
18
32
  commandChecks,
19
33
  ];
@@ -0,0 +1,51 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { listFiles } from "../lib/files.js";
4
+ const LISTING = ".context/listing";
5
+ // supply and deliver treat every folder in metadata/ as a locale, so a stray one aborts the upload.
6
+ const NOT_LOCALES = ["review_information", "trade_representative_contact_information", "app_clip_review_information"];
7
+ const LOCALE = /^[a-z]{2,3}(-([A-Za-z]{2}|[A-Za-z]{4}))?$/;
8
+ const PLAY_TEXT = ["title.txt", "short_description.txt", "full_description.txt"];
9
+ const PLAY_IMAGE_FOLDERS = ["phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", "tvScreenshots", "wearScreenshots"];
10
+ const folders = (path) => (existsSync(path) ? readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : []);
11
+ export const listing = {
12
+ id: "listing",
13
+ module: "listing",
14
+ description: "listing/ is fastlane's tree: metadata/ per store, locale folders only, Play's three text files, fastlane image folders, zero-padded screenshots",
15
+ run({ repo }) {
16
+ const root = join(repo, LISTING);
17
+ if (!existsSync(root))
18
+ return [];
19
+ const findings = [];
20
+ const add = (file, message) => findings.push({ check: "listing", level: "error", file, message });
21
+ for (const store of ["android", "ios"]) {
22
+ if (!existsSync(join(root, store)))
23
+ continue;
24
+ const metadata = join(root, store, "metadata");
25
+ if (!existsSync(metadata)) {
26
+ add(`${LISTING}/${store}/`, "no metadata/ folder");
27
+ continue;
28
+ }
29
+ for (const name of folders(metadata)) {
30
+ if (!NOT_LOCALES.includes(name) && !LOCALE.test(name))
31
+ add(`${LISTING}/${store}/metadata/${name}`, "not a locale — fastlane would upload it as one");
32
+ }
33
+ }
34
+ for (const locale of folders(join(root, "android", "metadata"))) {
35
+ const at = `${LISTING}/android/metadata/${locale}`;
36
+ for (const file of PLAY_TEXT)
37
+ if (!existsSync(join(repo, at, file)))
38
+ add(`${at}/${file}`, "missing");
39
+ for (const name of folders(join(repo, at, "images"))) {
40
+ if (!PLAY_IMAGE_FOLDERS.includes(name))
41
+ add(`${at}/images/${name}`, "not a fastlane image folder");
42
+ }
43
+ }
44
+ // Play ships screenshots in file-name order, so `2-` sorts after `10-`.
45
+ for (const file of listFiles(repo, LISTING, (name) => name.endsWith(".png"))) {
46
+ if (/[Ss]creenshots\//.test(file) && /\/\d-[^/]*$/.test(file))
47
+ add(file, "unpadded screenshot number — name it NN-<screen>.png");
48
+ }
49
+ return findings;
50
+ },
51
+ };
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readApprovals } from "../pipeline.js";
4
+ /**
5
+ * Stages run in the order zeno.yaml lists them. A stage has started once one
6
+ * of its `done` paths exists; a gated stage must be approved before any later
7
+ * stage starts. Each unapproved gate is reported once, at the first later
8
+ * stage that started.
9
+ */
10
+ export const pipeline = {
11
+ id: "pipeline",
12
+ module: null,
13
+ description: "no stage starts before every earlier gate is approved",
14
+ run({ repo, config }) {
15
+ const approved = new Set(readApprovals(repo).map((approval) => approval.stage));
16
+ const startedAt = config.pipeline.map((stage) => stage.done.find((path) => existsSync(join(repo, path))));
17
+ return config.pipeline.flatMap((gate, index) => {
18
+ if (!gate.gate || approved.has(gate.id))
19
+ return [];
20
+ const later = config.pipeline.findIndex((_, i) => i > index && startedAt[i] !== undefined);
21
+ if (later === -1)
22
+ return [];
23
+ const stage = config.pipeline[later];
24
+ return [
25
+ {
26
+ check: "pipeline",
27
+ level: "error",
28
+ file: startedAt[later],
29
+ message: `stage "${stage.id}" started before gate "${gate.id}" was approved — review ${gate.id}, then a person runs \`zeno approve ${gate.id}\``,
30
+ },
31
+ ];
32
+ });
33
+ },
34
+ };
@@ -0,0 +1,87 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join, normalize } from "node:path";
4
+ // A `.context/` path not preceded by something that makes it part of a longer one (another
5
+ // product's, an absolute path, a URL). A leading `./` means "beside this file".
6
+ const PATH = /(?<![\w/~.-])(\.\/)?(\.context\/[\w./@+-]*)/g;
7
+ const PLACEHOLDER = /[<{*[$]|YYYY|NNNN/;
8
+ const LINK = /\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g;
9
+ // A line reading a path out of git history names it as it was.
10
+ const FROM_HISTORY = /git (show|checkout|log|diff|restore)\b/;
11
+ // Dated records keep a path as it was on the day.
12
+ const DATED = /(^|\/)CHANGELOG\.md$|^\.context\/ops\/incidents\//;
13
+ const BINARY = /\.(png|jpe?g|gif|webp|ico|icns|mp4|mov|pdf|zip|aab|apk|jks|keystore|p8|p12|ttf|otf|woff2?|lock|snap|sqlite|db|bin)$|(^|\/)pnpm-lock\.yaml$/i;
14
+ const FEATURE_FILE = /^\.context\/features\/(\d{4})-[^/]+\.md$/;
15
+ function git(repo, args) {
16
+ const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
17
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
18
+ }
19
+ /** The part of a path that names something real: cut back to the last whole folder before a placeholder. */
20
+ function literalPart(token, restOfLine) {
21
+ let path = token.replace(/[.,;:]+$/, "");
22
+ const cut = PLACEHOLDER.exec(path);
23
+ if (cut || PLACEHOLDER.test(restOfLine.slice(0, 1))) {
24
+ path = cut ? path.slice(0, cut.index) : path;
25
+ path = path.slice(0, path.lastIndexOf("/") + 1);
26
+ }
27
+ return path.replace(/^[./]+|[./]+$/g, "") === "context" ? null : path;
28
+ }
29
+ export const refs = {
30
+ id: "refs",
31
+ module: null,
32
+ description: "every .context/ path a tracked file names exists; a [[link]] names a page (a link to a page not yet written is a note)",
33
+ run({ repo, config }) {
34
+ const listed = git(repo, ["ls-files", "-z"]);
35
+ if (listed.status !== 0)
36
+ return [];
37
+ const files = listed.stdout.split("\0").filter((file) => file && !BINARY.test(file));
38
+ // A copy of the source repo's file carries the source's paths; the product cannot edit it.
39
+ const copied = new Set(config.copies.files.flatMap((file) => (typeof file === "string" ? [file] : (file.to ?? [file.from]))));
40
+ const pages = new Set(files.filter((f) => f.startsWith(".context/wiki/") && f.endsWith(".md")).map((f) => basename(f, ".md")));
41
+ const features = new Set(files.flatMap((f) => (FEATURE_FILE.exec(f) ? [`FEAT-${FEATURE_FILE.exec(f)[1]}`] : [])));
42
+ const resolves = (target) => pages.has(target) || features.has(target) || existsSync(join(repo, ".context", `${target}.md`));
43
+ const exists = new Map();
44
+ const present = (path) => {
45
+ if (!exists.has(path)) {
46
+ exists.set(path, existsSync(join(repo, path)) || git(repo, ["check-ignore", "-q", path]).status === 0);
47
+ }
48
+ return exists.get(path);
49
+ };
50
+ const findings = [];
51
+ for (const file of files) {
52
+ if (copied.has(file))
53
+ continue;
54
+ let text;
55
+ try {
56
+ text = readFileSync(join(repo, file), "utf8");
57
+ }
58
+ catch {
59
+ continue; // deleted in the working tree, or a submodule
60
+ }
61
+ const dated = DATED.test(file);
62
+ const links = file.startsWith(".context/") && file.endsWith(".md");
63
+ text.split("\n").forEach((line, index) => {
64
+ const at = `${file}:${index + 1}`;
65
+ if (!dated && line.includes(".context/") && !FROM_HISTORY.test(line)) {
66
+ for (const match of line.matchAll(PATH)) {
67
+ let path = literalPart(match[2], line.slice(match.index + match[0].length));
68
+ if (path === null)
69
+ continue;
70
+ if (match[1])
71
+ path = normalize(join(dirname(file), path)) + (path.endsWith("/") ? "/" : "");
72
+ if (!present(path))
73
+ findings.push({ check: "refs", level: "error", file: at, message: `${path} does not exist` });
74
+ }
75
+ }
76
+ if (links && line.includes("[[")) {
77
+ for (const match of line.matchAll(LINK)) {
78
+ const target = match[1].trim();
79
+ if (!resolves(target))
80
+ findings.push({ check: "refs", level: "warn", file: at, message: `[[${target}]] names no page yet` });
81
+ }
82
+ }
83
+ });
84
+ }
85
+ return findings;
86
+ },
87
+ };
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { loadConfig } from "../config/load.js";
4
+ // zeno init writes the README heading `# .context — <Name>`; a product without a `name:` is read from it.
5
+ const README_NAME = /^# \.context — (.+)$/m;
6
+ function pairsOf(file) {
7
+ if (typeof file === "string")
8
+ return [{ from: file, to: file, required: true, named: false }];
9
+ return (file.to ?? [file.from]).map((to) => ({ from: file.from, to, required: false, named: file.named ?? false, until: file.until }));
10
+ }
11
+ function nameOf(repo, config) {
12
+ if (config.name)
13
+ return config.name;
14
+ const readme = join(repo, ".context", "README.md");
15
+ return existsSync(readme) ? README_NAME.exec(readFileSync(readme, "utf8"))?.[1]?.trim() : undefined;
16
+ }
17
+ function above(text, marker) {
18
+ const lines = text.split("\n");
19
+ const at = lines.findIndex((line) => line.includes(marker));
20
+ return (at === -1 ? lines : lines.slice(0, at)).join("\n");
21
+ }
22
+ function below(text, marker) {
23
+ const lines = text.split("\n");
24
+ const at = lines.findIndex((line) => line.includes(marker));
25
+ return at === -1 ? "" : lines.slice(at).join("\n");
26
+ }
27
+ /** Compares every shared file of `repo` with the source repo. Skipped files and files absent from the source are left out. */
28
+ export function compareCopies(repo, config) {
29
+ const { source, files, skip } = config.copies;
30
+ if (!source || files.length === 0 || resolve(source) === resolve(repo))
31
+ return [];
32
+ const skipped = new Set(skip.map((entry) => entry.file));
33
+ const productName = nameOf(repo, config);
34
+ const sourceName = nameOf(source, loadConfig(source).config);
35
+ const states = [];
36
+ for (const pair of files.flatMap(pairsOf)) {
37
+ const from = join(source, pair.from);
38
+ const to = join(repo, pair.to);
39
+ if (skipped.has(pair.to) || !existsSync(from))
40
+ continue;
41
+ if (!existsSync(to)) {
42
+ if (pair.required)
43
+ states.push({ pair, state: "missing" });
44
+ continue;
45
+ }
46
+ if (pair.named && (!productName || !sourceName)) {
47
+ states.push({ pair, state: "unnamed" });
48
+ continue;
49
+ }
50
+ const original = readFileSync(from, "utf8");
51
+ const current = readFileSync(to, "utf8");
52
+ let want = pair.named ? original.replaceAll(sourceName, productName) : original;
53
+ let have = current;
54
+ if (pair.until) {
55
+ want = above(want, pair.until);
56
+ have = above(current, pair.until);
57
+ }
58
+ if (want === have)
59
+ states.push({ pair, state: "same" });
60
+ // Without the marker the product's own part cannot be told from the shared one, so nothing is written.
61
+ else if (pair.until && !current.includes(pair.until))
62
+ states.push({ pair, state: "drift" });
63
+ else
64
+ states.push({ pair, state: "drift", want: pair.until ? `${want}\n${below(current, pair.until)}` : want });
65
+ }
66
+ return states;
67
+ }
68
+ /** Overwrites each drifted copy with the source's. Never adds a missing file. Returns the paths written. */
69
+ export function syncCopies(repo, config) {
70
+ const written = [];
71
+ for (const { pair, state, want } of compareCopies(repo, config)) {
72
+ if (state !== "drift" || want === undefined)
73
+ continue;
74
+ writeFileSync(join(repo, pair.to), want);
75
+ written.push(pair.to);
76
+ }
77
+ return written;
78
+ }
79
+ export const sharedFiles = {
80
+ id: "shared-files",
81
+ module: null,
82
+ description: "files copied from the copies.source repo are identical to it; one a product does without is skipped with a reason",
83
+ run({ repo, config }) {
84
+ const source = config.copies.source ? basename(config.copies.source) : "";
85
+ return compareCopies(repo, config).flatMap(({ pair, state }) => {
86
+ const finding = (message) => [{ check: "shared-files", level: "error", file: pair.to, message }];
87
+ if (state === "missing")
88
+ return finding(`missing — copy it from ${source}, or add it under copies.skip with a reason`);
89
+ if (state === "unnamed")
90
+ return finding(`no product name to compare with — set name: in zeno.yaml`);
91
+ if (state === "drift")
92
+ return finding(`differs from ${source}${pair.until ? " above the shared block's end" : ""} — \`zeno copies --sync\` copies it`);
93
+ return [];
94
+ });
95
+ },
96
+ };