honestdocs 0.0.0-stage → 0.1.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.
@@ -0,0 +1,166 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { c, die, ok, parseArgs, say } from "../cli.mjs";
4
+ import { CONFIG_FILE, CONFIG_IN_ROOT, DEFAULT_AREAS, DEFAULT_ROOT, findConfig, loadConfig } from "../config.mjs";
5
+ import { today } from "../docs.mjs";
6
+ import { stringify } from "../frontmatter.mjs";
7
+ import { fill, packageFile } from "../templates.mjs";
8
+ import { report, runIndex } from "./index.mjs";
9
+
10
+ export const summary = "set honestdocs up in this project";
11
+
12
+ export const help = `honestdocs init — ${summary}
13
+
14
+ honestdocs init [--root <dir>] [--area <name[=description]>]… [--code-repo <path>]…
15
+ [--title <text>] [--agents] [--agents-file <file>] [--skill]
16
+
17
+ Creates ${DEFAULT_ROOT}/ with its config, a README, a first rule document
18
+ (rules/…-before-you-build.md) and INDEX.md. Refuses a folder that already
19
+ holds Markdown files — your own docs stay yours.
20
+
21
+ --root <dir> the documents folder (default: ${DEFAULT_ROOT}). With
22
+ any other folder the config goes to ${CONFIG_FILE}
23
+ at the top of the project instead.
24
+ --area <name> an area; repeat for more (default: general, rules).
25
+ "rules" is always added.
26
+ --code-repo <path> where commits carrying the trailer are read from
27
+ (default: "."). Repeat for several repositories, or
28
+ point at the parent when the documents are a separate
29
+ repository nested inside the code.
30
+ --title <text> the heading of INDEX.md
31
+ --agents add the honestdocs section to AGENTS.md (created if
32
+ missing; updated in place between markers on re-runs)
33
+ --agents-file <file> the same, into another file — CLAUDE.md, say
34
+ --skill install the agent skill at .claude/skills/honestdocs/
35
+
36
+ Run again in a project that is already set up, --agents and --skill refresh
37
+ those two and change nothing else.
38
+ `;
39
+
40
+ function hasMarkdown(dir) {
41
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
42
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
43
+ if (entry.isDirectory() ? hasMarkdown(join(dir, entry.name)) : entry.name.endsWith(".md")) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ const START = "<!-- honestdocs:start -->";
49
+ const END = "<!-- honestdocs:end -->";
50
+
51
+ /** Insert or replace the marked block, leaving everything else in the file alone. */
52
+ function upsertBlock(file, block) {
53
+ const wrapped = `${START}\n${block.trim()}\n${END}`;
54
+ if (!existsSync(file)) {
55
+ writeFileSync(file, wrapped + "\n");
56
+ return "created";
57
+ }
58
+ const text = readFileSync(file, "utf8");
59
+ const a = text.indexOf(START);
60
+ const b = text.indexOf(END);
61
+ if (a >= 0 && b > a) {
62
+ const next = text.slice(0, a) + wrapped + text.slice(b + END.length);
63
+ if (next === text) return "unchanged";
64
+ writeFileSync(file, next);
65
+ return "updated";
66
+ }
67
+ writeFileSync(file, text.replace(/\s*$/, "\n\n") + wrapped + "\n");
68
+ return "appended";
69
+ }
70
+
71
+ function installAgentBits(base, args, root, trailer) {
72
+ const values = { root, trailer };
73
+ if (args.agents || args["agents-file"]) {
74
+ const file = resolve(base, args["agents-file"] ?? "AGENTS.md");
75
+ const how = upsertBlock(file, fill(packageFile("guide", "agents-snippet.md"), values));
76
+ ok(`${relative(base, file)} — honestdocs section ${how}`);
77
+ }
78
+ if (args.skill) {
79
+ const file = join(base, ".claude", "skills", "honestdocs", "SKILL.md");
80
+ mkdirSync(dirname(file), { recursive: true });
81
+ writeFileSync(file, fill(packageFile("guide", "skill", "SKILL.md"), values));
82
+ ok(`${relative(base, file).split("\\").join("/")} — skill installed`);
83
+ }
84
+ }
85
+
86
+ export function run(argv) {
87
+ const args = parseArgs(argv, {
88
+ root: "string", area: "list", "code-repo": "list", title: "string",
89
+ agents: "boolean", "agents-file": "string", skill: "boolean",
90
+ });
91
+ const cwd = process.cwd();
92
+
93
+ const existing = findConfig(cwd);
94
+ if (existing) {
95
+ const config = loadConfig(cwd);
96
+ if (!args.agents && !args["agents-file"] && !args.skill) {
97
+ die(`already set up: ${existing.file} — re-run with --agents or --skill to refresh those`);
98
+ }
99
+ installAgentBits(config.base, args, config.root, config.trailer);
100
+ return;
101
+ }
102
+
103
+ const root = (args.root ?? DEFAULT_ROOT).replace(/[\\/]+$/, "").replace(/^\.[\\/]/, "");
104
+ const target = resolve(cwd, root);
105
+ if (existsSync(target) && hasMarkdown(target)) {
106
+ die(`${root}/ already holds Markdown files — choose another folder with --root`);
107
+ }
108
+ const areas = {};
109
+ for (const spec of args.area.length ? args.area : Object.keys(DEFAULT_AREAS)) {
110
+ const [name, ...rest] = spec.split("=");
111
+ areas[name.trim()] = rest.join("=").trim() || DEFAULT_AREAS[name.trim()] || "";
112
+ }
113
+ if (!areas.rules) areas.rules = DEFAULT_AREAS.rules;
114
+
115
+ const raw = {
116
+ root,
117
+ title: args.title ?? "Development documents",
118
+ areas,
119
+ trailer: "Doc",
120
+ codeRepos: args["code-repo"].length ? args["code-repo"] : ["."],
121
+ };
122
+ const configPath = root === DEFAULT_ROOT ? CONFIG_IN_ROOT : CONFIG_FILE;
123
+ mkdirSync(dirname(join(cwd, configPath)), { recursive: true });
124
+ writeFileSync(join(cwd, configPath), JSON.stringify(raw, null, 2) + "\n");
125
+ ok(configPath);
126
+
127
+ const config = loadConfig(cwd);
128
+ mkdirSync(join(config.docsDir, "rules"), { recursive: true });
129
+
130
+ const readme = join(config.docsDir, "README.md");
131
+ if (!existsSync(readme)) {
132
+ writeFileSync(readme, fill(packageFile("guide", "docs-readme.md"), { root, trailer: config.trailer }));
133
+ ok(`${root}/README.md`);
134
+ }
135
+
136
+ const id = `${today()}-before-you-build`;
137
+ const rulesFile = join(config.docsDir, "rules", `${id}.md`);
138
+ const meta = {
139
+ id,
140
+ title: "Before you build: the checklist every change goes through",
141
+ area: "rules",
142
+ type: "rule",
143
+ status: "current",
144
+ created: today(),
145
+ updated: today(),
146
+ completed: "",
147
+ progress: "",
148
+ supersedes: [],
149
+ "superseded-by": "",
150
+ related: [],
151
+ files: [],
152
+ };
153
+ writeFileSync(rulesFile, stringify(meta) + fill(packageFile("guide", "before-you-build.md"), { root, trailer: config.trailer }));
154
+ ok(`${root}/rules/${id}.md`);
155
+
156
+ installAgentBits(config.base, args, root, config.trailer);
157
+ report(config, runIndex(config), false);
158
+
159
+ say("");
160
+ const area = Object.keys(config.areas).find((a) => a !== "rules") ?? "rules";
161
+ say("Next:");
162
+ say(c.dim(` start a document honestdocs new ${area} my-first-design --title "…"`));
163
+ say(c.dim(` link work to it git commit -m "…" -m "${config.trailer}: <id>"`));
164
+ say(c.dim(" keep it honest honestdocs index && honestdocs check"));
165
+ if (!args.agents && !args["agents-file"]) say(c.dim(" teach your agents honestdocs init --agents"));
166
+ }
@@ -0,0 +1,91 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { UsageError, c, die, emitJson, ok, parseArgs, say } from "../cli.mjs";
4
+ import { loadConfig, statusesFor } from "../config.mjs";
5
+ import { loadDocs, slugify, today } from "../docs.mjs";
6
+ import { stringify } from "../frontmatter.mjs";
7
+ import { fill, templateFor } from "../templates.mjs";
8
+
9
+ export const summary = "start a document";
10
+
11
+ export const help = `honestdocs new — ${summary}
12
+
13
+ honestdocs new <area> <slug> --title "<title>" [--type <type>]
14
+
15
+ Writes <root>/<area>/<YYYY-MM-DD>-<slug>.md with its frontmatter filled in and
16
+ the body template for its type, and prints the path on stdout.
17
+ With a single area configured, <area> may be left out.
18
+
19
+ --type <type> default: design
20
+ --status <status> default: draft
21
+ --supersedes <id> this document replaces <id> (repeatable)
22
+ --related <id> a related document or path (repeatable)
23
+ --dry-run print what would be written, write nothing
24
+ --json { id, path, type, status } on stdout
25
+ `;
26
+
27
+ export function run(argv) {
28
+ const args = parseArgs(argv, {
29
+ type: "string", title: "string", status: "string",
30
+ supersedes: "list", related: "list", "dry-run": "boolean", json: "boolean",
31
+ });
32
+ const config = loadConfig();
33
+ const areas = Object.keys(config.areas);
34
+
35
+ let [area, rawSlug] = args._;
36
+ if (args._.length === 1 && areas.length === 1) [area, rawSlug] = [areas[0], args._[0]];
37
+ if (!area || !rawSlug) throw new UsageError("need <area> and <slug>");
38
+ if (!areas.includes(area)) die(`unknown area "${area}" — expected one of: ${areas.join(", ")}`);
39
+
40
+ const type = args.type ?? "design";
41
+ if (!config.types[type]) die(`unknown type "${type}" — expected one of: ${Object.keys(config.types).join(", ")}`);
42
+
43
+ const statuses = statusesFor(config, type);
44
+ const status = args.status ?? "draft";
45
+ if (!statuses[status]) die(`"${status}" is not a status for a ${type} — expected one of: ${Object.keys(statuses).join(", ")}`);
46
+
47
+ if (!args.title) throw new UsageError('need --title "…" — an index of ids nobody can read is no index');
48
+
49
+ const slug = slugify(rawSlug);
50
+ if (!slug) die(`"${rawSlug}" leaves nothing usable for a filename — use letters and digits`);
51
+ const id = `${today()}-${slug}`;
52
+ const docs = loadDocs(config);
53
+ if (docs.some((d) => d.id === id)) die(`a document with the id ${id} already exists`);
54
+ for (const target of args.supersedes) {
55
+ if (!docs.some((d) => d.id === target)) die(`--supersedes ${target}: no document has that id`);
56
+ }
57
+
58
+ const meta = {
59
+ id,
60
+ title: args.title,
61
+ area,
62
+ type,
63
+ status,
64
+ created: today(),
65
+ updated: today(),
66
+ completed: "",
67
+ progress: "",
68
+ supersedes: args.supersedes,
69
+ "superseded-by": "",
70
+ related: args.related,
71
+ files: [],
72
+ };
73
+ const content = stringify(meta) + fill(templateFor(config, type), { title: args.title, id, date: today() });
74
+ const file = join(config.docsDir, area, `${id}.md`);
75
+ const shown = relative(config.base, file).split("\\").join("/");
76
+
77
+ if (args["dry-run"]) {
78
+ say(c.dim(`would write ${shown}`));
79
+ say(content);
80
+ return;
81
+ }
82
+ if (existsSync(file)) die(`${shown} already exists`);
83
+ mkdirSync(join(config.docsDir, area), { recursive: true });
84
+ writeFileSync(file, content);
85
+
86
+ if (args.json) return emitJson({ id, path: shown, type, status });
87
+ ok(c.dim(`${type} · ${area} · ${status}`));
88
+ say(c.dim(` Put "${config.trailer}: ${id}" in the commits that do this work —`));
89
+ say(c.dim(" that is how `honestdocs index` learns which files it touched."));
90
+ console.log(shown);
91
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import { UsageError, c, die, ok, parseArgs, say } from "../cli.mjs";
4
+ import { asList, findDoc, loadDocs, writeDoc } from "../docs.mjs";
5
+ import { loadConfig } from "../config.mjs";
6
+
7
+ export const summary = "scaffold a public document from a development document";
8
+
9
+ export const help = `honestdocs promote — ${summary}
10
+
11
+ honestdocs promote <id> --to <path.md> [--dry-run]
12
+
13
+ Creates <path.md> with the source's headings and nothing else, and links the two
14
+ both ways. The prose is not copied on purpose: a development document explains a
15
+ decision to the people who were there; a public document explains the result to
16
+ someone who was not. Translating one into the other serves neither.
17
+ `;
18
+
19
+ export function run(argv) {
20
+ const args = parseArgs(argv, { to: "string", "dry-run": "boolean" });
21
+ const [id] = args._;
22
+ if (!id || !args.to) throw new UsageError("need <id> and --to <path.md>");
23
+ const config = loadConfig();
24
+ const doc = findDoc(loadDocs(config), id);
25
+ if (!doc) die(`no document with the id "${id}"`);
26
+
27
+ const out = isAbsolute(args.to) ? args.to : resolve(process.cwd(), args.to);
28
+ const shown = relative(config.base, out).split("\\").join("/");
29
+ if (shown.startsWith("..")) die(`--to must be inside the project (${config.base})`);
30
+ if (existsSync(out)) die(`${shown} already exists`);
31
+
32
+ const headings = doc.body.split(/\r?\n/).filter((l) => /^#{2,3} /.test(l));
33
+ const skeleton = [
34
+ `# ${doc.meta.title ?? id}`,
35
+ "",
36
+ `<!-- Started from the development document ${id}. Written for someone who has never`,
37
+ " seen this project: what it does, how to use it, what goes wrong. Why it was",
38
+ ` chosen stays in ${config.root}/. -->`,
39
+ "",
40
+ ...headings.flatMap((h) => [h, "", "_TODO_", ""]),
41
+ ].join("\n");
42
+
43
+ if (args["dry-run"]) {
44
+ say(c.yellow(`dry run — would write ${shown}`));
45
+ say(skeleton);
46
+ return;
47
+ }
48
+ mkdirSync(dirname(out), { recursive: true });
49
+ writeFileSync(out, skeleton);
50
+ doc.meta.related = [...new Set([...asList(doc.meta.related), shown])];
51
+ writeDoc(doc);
52
+ ok(shown);
53
+ say(c.dim(` ${doc.rel} now lists it under related.`));
54
+ console.log(shown);
55
+ }
@@ -0,0 +1,51 @@
1
+ import { emitJson, parseArgs } from "../cli.mjs";
2
+ import { loadConfig } from "../config.mjs";
3
+ import { loadDocs } from "../docs.mjs";
4
+ import { parseRules } from "../rules.mjs";
5
+
6
+ export const summary = "list the rules in force, with the tests that prove them";
7
+
8
+ export const help = `honestdocs rules — ${summary}
9
+
10
+ honestdocs rules [<id-prefix>] [--all] [--json]
11
+
12
+ Reads every document of type "rule" that is current (or still a draft) and
13
+ lists its numbered rules. Give a prefix — R, SEC — to see one family.
14
+
15
+ --all include retired and superseded rule documents
16
+ --json the full list on stdout
17
+ `;
18
+
19
+ function collectRules(config, { all = false, prefix = "" } = {}) {
20
+ return loadDocs(config)
21
+ .filter((d) => d.hasMeta && d.meta.type === "rule")
22
+ .filter((d) => all || d.meta.status === "current" || d.meta.status === "draft")
23
+ .map((d) => ({
24
+ id: d.id,
25
+ path: `${config.root}/${d.rel}`,
26
+ title: d.meta.title,
27
+ status: d.meta.status,
28
+ rules: parseRules(d.body)
29
+ .filter((r) => !prefix || r.id.startsWith(prefix.toUpperCase()))
30
+ .map(({ id, text, tests, line }) => ({ id, text, tests, line })),
31
+ }))
32
+ .filter((d) => !prefix || d.rules.length);
33
+ }
34
+
35
+ export function run(argv) {
36
+ const args = parseArgs(argv, { all: "boolean", json: "boolean" });
37
+ const config = loadConfig();
38
+ const docs = collectRules(config, { all: args.all, prefix: args._[0] ?? "" });
39
+ if (args.json) return emitJson(docs);
40
+ if (!docs.length) return console.log("no rule documents in force");
41
+ const out = [];
42
+ for (const d of docs) {
43
+ out.push(`${d.path} — ${d.title} [${d.status}]`);
44
+ for (const r of d.rules) {
45
+ const text = r.text.length > 110 ? r.text.slice(0, 107) + "…" : r.text;
46
+ out.push(` ${r.id.padEnd(5)} ${text}${r.tests.length ? `\n test: ${r.tests.join(", ")}` : ""}`);
47
+ }
48
+ out.push("");
49
+ }
50
+ console.log(out.join("\n").trimEnd());
51
+ }
@@ -0,0 +1,88 @@
1
+ import { UsageError, emitJson, parseArgs } from "../cli.mjs";
2
+ import { loadConfig } from "../config.mjs";
3
+ import { loadDocs } from "../docs.mjs";
4
+ import { MARK } from "../render.mjs";
5
+
6
+ export const summary = "find documents — including the superseded and abandoned ones";
7
+
8
+ export const help = `honestdocs search — ${summary}
9
+
10
+ honestdocs search <words…> [--section <heading>] [--status <s>] [--area <a>]
11
+ [--type <t>] [--limit <n>] [--json]
12
+
13
+ Every word must appear in the document (case-insensitive). Results show the
14
+ matching lines under their section heading.
15
+
16
+ Search before proposing a design: if the idea was weighed and rejected, the
17
+ reason is in some document's "Alternatives considered".
18
+
19
+ --section <heading> only lines under headings containing this text,
20
+ e.g. --section alternatives
21
+ --limit <n> at most n documents (default 20)
22
+ `;
23
+
24
+ /** Split a body into `{ heading, line, text }` for each non-empty line. */
25
+ function linesWithHeadings(body) {
26
+ const out = [];
27
+ let heading = "";
28
+ let inFence = false;
29
+ body.split(/\r?\n/).forEach((text, i) => {
30
+ if (/^\s*(```|~~~)/.test(text)) inFence = !inFence;
31
+ const h = !inFence && /^#{1,6}\s+(.*)$/.exec(text);
32
+ if (h) heading = h[1].trim();
33
+ else if (text.trim()) out.push({ heading, line: i + 1, text: text.trim() });
34
+ });
35
+ return out;
36
+ }
37
+
38
+ export function search(config, words, opts = {}) {
39
+ const terms = words.map((w) => w.toLowerCase()).filter(Boolean);
40
+ const section = opts.section?.toLowerCase();
41
+ const hits = [];
42
+ for (const d of loadDocs(config)) {
43
+ const m = d.meta;
44
+ if (opts.status && m.status !== opts.status) continue;
45
+ if (opts.area && m.area !== opts.area) continue;
46
+ if (opts.type && m.type !== opts.type) continue;
47
+ const lines = linesWithHeadings(d.body).filter((l) => !section || l.heading.toLowerCase().includes(section));
48
+ const title = String(m.title ?? "").toLowerCase();
49
+ const haystack = (section ? "" : title + "\n") + lines.map((l) => l.text.toLowerCase()).join("\n");
50
+ if (!terms.every((t) => haystack.includes(t))) continue;
51
+ const matching = lines.filter((l) => terms.some((t) => l.text.toLowerCase().includes(t)));
52
+ const score = terms.filter((t) => title.includes(t)).length * 10 + matching.length;
53
+ hits.push({
54
+ id: d.id,
55
+ path: `${config.root}/${d.rel}`,
56
+ title: m.title ?? "",
57
+ status: m.status ?? "",
58
+ type: m.type ?? "",
59
+ score,
60
+ matches: matching.map((l) => ({ section: l.heading, line: l.line, text: l.text })),
61
+ });
62
+ }
63
+ hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
64
+ return hits;
65
+ }
66
+
67
+ export function run(argv) {
68
+ const args = parseArgs(argv, {
69
+ section: "string", status: "string", area: "string", type: "string", limit: "string", json: "boolean",
70
+ });
71
+ if (!args._.length) throw new UsageError("need at least one word to search for");
72
+ const config = loadConfig();
73
+ const limit = Number(args.limit ?? 20) || 20;
74
+ const hits = search(config, args._, args).slice(0, limit);
75
+ if (args.json) return emitJson(hits);
76
+ if (!hits.length) return console.log("no document matches");
77
+ const out = [];
78
+ for (const h of hits) {
79
+ const flag = h.status === "superseded" || h.status === "abandoned" || h.status === "retired" ? ` [${h.status.toUpperCase()}]` : "";
80
+ out.push(`${MARK[h.status] ?? " "} ${h.path} — ${h.title}${flag}`);
81
+ for (const m of h.matches.slice(0, 3)) {
82
+ const text = m.text.length > 140 ? m.text.slice(0, 137) + "…" : m.text;
83
+ out.push(` ${m.section ? `§ ${m.section}: ` : ""}${text}`);
84
+ }
85
+ if (h.matches.length > 3) out.push(` … ${h.matches.length - 3} more lines`);
86
+ }
87
+ console.log(out.join("\n"));
88
+ }
@@ -0,0 +1,43 @@
1
+ import { UsageError, die, ok, parseArgs } from "../cli.mjs";
2
+ import { lifecycleOf, loadConfig, statusesFor } from "../config.mjs";
3
+ import { findDoc, loadDocs, today, writeDoc } from "../docs.mjs";
4
+ import { report, runIndex } from "./index.mjs";
5
+
6
+ export const summary = "move a document to another status";
7
+
8
+ export const help = `honestdocs status — ${summary}
9
+
10
+ honestdocs status <id> <status>
11
+
12
+ Work documents: draft · planned · in-progress (finish with \`honestdocs done\`)
13
+ Living documents: draft · current · retired
14
+
15
+ "superseded" is never set by hand: write \`supersedes: [<old id>]\` in the newer
16
+ document and \`honestdocs index\` marks the old one.
17
+ `;
18
+
19
+ export function run(argv) {
20
+ const args = parseArgs(argv, {});
21
+ const [id, status] = args._;
22
+ if (!id || !status) throw new UsageError("need <id> and <status>");
23
+ const config = loadConfig();
24
+ const doc = findDoc(loadDocs(config), id);
25
+ if (!doc) die(`no document with the id "${id}"`);
26
+ if (!doc.hasMeta) die(`${doc.rel} has no frontmatter`);
27
+
28
+ const allowed = statusesFor(config, doc.meta.type);
29
+ if (!allowed) die(`${doc.rel} has an unknown type "${doc.meta.type}"`);
30
+ if (status === "superseded") {
31
+ die("set `supersedes: [" + id + "]` on the document that replaces it, then run `honestdocs index`");
32
+ }
33
+ if (status === "done" || status === "abandoned") die(`use \`honestdocs done ${id}${status === "abandoned" ? " --abandoned" : ""}\` — it checks the checklist first`);
34
+ if (!allowed[status]) {
35
+ die(`"${status}" is not a status for a ${doc.meta.type} (${lifecycleOf(config, doc.meta.type)}) — expected one of: ${Object.keys(allowed).filter((s) => s !== "superseded").join(", ")}`);
36
+ }
37
+
38
+ doc.meta.status = status;
39
+ if (status === "retired") doc.meta.completed = today();
40
+ writeDoc(doc);
41
+ ok(`${id} → ${status}`);
42
+ report(config, runIndex(config), false);
43
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // Finding and reading honestdocs.config.json.
2
+ //
3
+ // The config is found by walking up from the working directory, the way git finds .git,
4
+ // so every command works from anywhere inside the project. Everything a project may want
5
+ // to differ on — areas, the docs folder, the trailer word, where the code lives — is here;
6
+ // the lifecycles are not, because the checks only mean something if "done" and "current"
7
+ // mean the same thing in every project that uses this.
8
+
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { dirname, isAbsolute, join, resolve } from "node:path";
11
+ import { Failure } from "./cli.mjs";
12
+
13
+ /** The default home: documents and config together, out of the way of the project's own folders. */
14
+ export const DEFAULT_ROOT = ".honestdocs";
15
+ export const CONFIG_IN_ROOT = `${DEFAULT_ROOT}/config.json`;
16
+ /** For a project that keeps its documents somewhere else, the config sits at the top. */
17
+ export const CONFIG_FILE = "honestdocs.config.json";
18
+
19
+ /**
20
+ * Two kinds of document, with two lifecycles.
21
+ *
22
+ * Work documents describe something that gets done and then is finished: they move
23
+ * towards `done` or `abandoned`. Living documents — references and rules — are never
24
+ * finished; they are true now and edited in place, so they are `current` until retired.
25
+ * Forcing a standing rule through "draft → in-progress → done" leaves it in `draft`
26
+ * forever while everyone follows it, which is its own kind of lie.
27
+ */
28
+ export const LIFECYCLES = {
29
+ work: {
30
+ draft: "Being written; not ready to build from",
31
+ planned: "Decided and written down, but no code yet — re-check it against reality before building",
32
+ "in-progress": "Decided, and the work is under way",
33
+ done: "Finished and shipped",
34
+ superseded: "A newer document replaced this one — do not build from it",
35
+ abandoned: "Deliberately not done; kept so the reasoning survives",
36
+ },
37
+ living: {
38
+ draft: "Being written; not in force yet",
39
+ current: "In force: true today, kept true in place",
40
+ retired: "No longer in force; kept so the reasoning survives",
41
+ superseded: "A newer document replaced this one — follow that one instead",
42
+ },
43
+ };
44
+
45
+ const DEFAULT_TYPES = {
46
+ research: { lifecycle: "work", description: "A question and what was found out, with sources" },
47
+ design: { lifecycle: "work", description: "How something should work, decided before building it" },
48
+ plan: { lifecycle: "work", description: "A sequence of work with a checklist that drives progress" },
49
+ decision: { lifecycle: "work", description: "One choice, its context and its consequences (MADR)" },
50
+ postmortem: { lifecycle: "work", description: "Something went wrong: timeline, impact, root cause, what changed" },
51
+ reference: { lifecycle: "living", description: "Background that stays true: a protocol, an API, a guide" },
52
+ rule: { lifecycle: "living", description: "Numbered rules every change must keep, each naming its test where it can" },
53
+ };
54
+
55
+ export const DEFAULT_AREAS = {
56
+ general: "Anything that does not belong to a narrower area yet",
57
+ rules: "Standing rules every change must keep — read before building, updated in place",
58
+ };
59
+
60
+ /**
61
+ * Walk up from `start` to the nearest project: a folder holding `.honestdocs/config.json`
62
+ * or `honestdocs.config.json`. Returns `{ file, base }` — `base` is the project folder,
63
+ * which every relative path in the config is read against — or null.
64
+ */
65
+ export function findConfig(start = process.cwd()) {
66
+ let dir = resolve(start);
67
+ for (;;) {
68
+ const found = [join(dir, CONFIG_IN_ROOT), join(dir, CONFIG_FILE)].filter((f) => existsSync(f));
69
+ if (found.length > 1) {
70
+ throw new Failure(`two configs in ${dir}: ${CONFIG_IN_ROOT} and ${CONFIG_FILE} — keep one`);
71
+ }
72
+ if (found.length) return { file: found[0], base: dir };
73
+ const parent = dirname(dir);
74
+ if (parent === dir) return null;
75
+ dir = parent;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Load and validate the config. Returns a normalised object with absolute paths, so no
81
+ * command ever has to think about where it was started from.
82
+ */
83
+ export function loadConfig(start = process.cwd()) {
84
+ const found = findConfig(start);
85
+ if (!found) throw new Failure(`no ${CONFIG_IN_ROOT} here or above — run \`honestdocs init\` first`);
86
+ let raw;
87
+ try {
88
+ raw = JSON.parse(readFileSync(found.file, "utf8"));
89
+ } catch (e) {
90
+ throw new Failure(`${found.file} is not valid JSON: ${e.message}`);
91
+ }
92
+ return normalise(raw, found.base, found.file);
93
+ }
94
+
95
+ function normalise(raw, base, file = join(base, CONFIG_FILE)) {
96
+ const problems = [];
97
+ const root = typeof raw.root === "string" && raw.root ? raw.root : DEFAULT_ROOT;
98
+
99
+ const areas = raw.areas && typeof raw.areas === "object" ? raw.areas : DEFAULT_AREAS;
100
+ if (!Object.keys(areas).length) problems.push("`areas` is empty — a document needs somewhere to live");
101
+ for (const a of Object.keys(areas)) {
102
+ if (!/^[a-z0-9_][a-z0-9_-]*$/.test(a)) problems.push(`area "${a}" must be lower-case letters, digits, - or _`);
103
+ }
104
+
105
+ const types = { ...DEFAULT_TYPES };
106
+ for (const [name, def] of Object.entries(raw.types ?? {})) {
107
+ if (def === false || def === null) {
108
+ delete types[name];
109
+ continue;
110
+ }
111
+ const lifecycle = def?.lifecycle ?? types[name]?.lifecycle;
112
+ if (!LIFECYCLES[lifecycle]) {
113
+ problems.push(`type "${name}" needs a lifecycle: ${Object.keys(LIFECYCLES).join(" or ")}`);
114
+ continue;
115
+ }
116
+ types[name] = { lifecycle, description: def?.description ?? types[name]?.description ?? "" };
117
+ }
118
+
119
+ const trailer = typeof raw.trailer === "string" && raw.trailer ? raw.trailer : "Doc";
120
+ if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(trailer)) problems.push(`trailer "${trailer}" must be a git trailer key such as Doc`);
121
+
122
+ const codeRepos = (Array.isArray(raw.codeRepos) && raw.codeRepos.length ? raw.codeRepos : ["."])
123
+ .map((p) => (isAbsolute(p) ? p : resolve(base, p)));
124
+
125
+ if (problems.length) throw new Failure(`${file}:\n ${problems.join("\n ")}`);
126
+
127
+ return {
128
+ file,
129
+ base,
130
+ root,
131
+ docsDir: resolve(base, root),
132
+ title: typeof raw.title === "string" && raw.title ? raw.title : "Development documents",
133
+ areas,
134
+ types,
135
+ trailer,
136
+ codeRepos,
137
+ templatesDir: raw.templates ? resolve(base, raw.templates) : null,
138
+ recent: Number.isInteger(raw.recent) && raw.recent > 0 ? raw.recent : 25,
139
+ raw,
140
+ };
141
+ }
142
+
143
+ /** The statuses a document of this type may have. */
144
+ export const statusesFor = (config, type) => LIFECYCLES[config.types[type]?.lifecycle] ?? null;
145
+
146
+ export const lifecycleOf = (config, type) => config.types[type]?.lifecycle ?? null;