@taprootio/trellis 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.
package/src/cli.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // Shared helpers for the dependency-free CLI wrappers.
2
+
3
+ import { resolve } from "node:path";
4
+
5
+ export function optionToken(arg) {
6
+ const eq = arg.indexOf("=");
7
+ return {
8
+ key: arg.startsWith("--") && eq !== -1 ? arg.slice(0, eq) : arg,
9
+ inline: arg.startsWith("--") && eq !== -1 ? arg.slice(eq + 1) : null,
10
+ };
11
+ }
12
+
13
+ export function usageError(message) {
14
+ console.error(message);
15
+ process.exit(2);
16
+ }
17
+
18
+ export function showHelp(help) {
19
+ process.stdout.write(help);
20
+ process.exit(0);
21
+ }
22
+
23
+ export function requiredValue(argv, index, inline, flag) {
24
+ const value = inline !== null ? inline : argv[index + 1];
25
+ if (value === undefined || value === "" || (inline === null && value.startsWith("-"))) {
26
+ usageError(`error: ${flag} requires a value`);
27
+ }
28
+ return { value, index: inline !== null ? index : index + 1 };
29
+ }
30
+
31
+ export function resolveRepoRoot(value) {
32
+ return resolve(value || process.cwd());
33
+ }
@@ -0,0 +1,204 @@
1
+ // Trellis git-derived task history (SPEC §8.4 — a derived, NON-gated report).
2
+ //
3
+ // A lightweight per-task change log — who changed an item, when, and why —
4
+ // reconstructed from git, plus a materialized `history.json` a static viewer can
5
+ // read without a git runtime. This is deliberately a SEPARATE module from the
6
+ // generator core (src/backlog.mjs): keeping every `git` invocation out of the core
7
+ // structurally guarantees that `backlog:check` (the deterministic gate, SPEC §8.3)
8
+ // never depends on git history. History is volatile by nature (commit times,
9
+ // authors), so it is never part of `--check` and never written by the generator.
10
+ //
11
+ // git is the authoritative deep record; this module only reads it. Imported items
12
+ // (TRL0021/22) carry history from the import commit forward — a single-commit or
13
+ // unborn-HEAD history yields `[]`, never a crash. Per-task derivation uses
14
+ // `git log --follow` so history survives the active→completed move (and, as it
15
+ // happens, the `PL→TRL` prefix migration and the `docs/tasks → trellis/`
16
+ // relocation, which `--follow` also tracks through the renames).
17
+
18
+ import { existsSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
19
+ import { join, relative, dirname, isAbsolute } from "node:path";
20
+ import { execFileSync } from "node:child_process";
21
+ import { paths } from "./backlog.mjs";
22
+
23
+ // A typed failure the callers classify: the CLI prints it and exits non-zero; the
24
+ // MCP adapter maps it to a TrellisError (an isError result), so neither surfaces a
25
+ // raw stack trace. `code` is a short slug for programmatic handling.
26
+ export class HistoryError extends Error {
27
+ constructor(message, code = "history_failed") {
28
+ super(message);
29
+ this.name = "HistoryError";
30
+ this.code = code;
31
+ }
32
+ }
33
+
34
+ // Field/record delimiters for the `git log` custom format. ASCII unit/record
35
+ // separators (0x1F/0x1E) cannot appear in commit subjects, author names, dates, or
36
+ // single-line trailer values, so the output parses unambiguously without quoting.
37
+ const FIELD = "\x1f";
38
+ const RECORD = "\x1e";
39
+
40
+ // commit (full SHA) · author date (strict ISO 8601) · author name · subject ·
41
+ // the Trellis-Reason trailer value (empty when absent → reason falls back to the
42
+ // subject). Multiple Trellis-Reason trailers are joined with a comma.
43
+ const FORMAT =
44
+ "%H%x1f%aI%x1f%an%x1f%s%x1f%(trailers:key=Trellis-Reason,valueonly,separator=%x2c)%x1e";
45
+
46
+ // Run git with an arg array (NO shell) rooted at repoRoot. Errors propagate to the
47
+ // caller, which classifies them (missing git, not a repo, unborn HEAD, real failure).
48
+ function git(repoRoot, args) {
49
+ // Capture stderr (don't inherit it — the *Sync exec default): it feeds gitMsg() on
50
+ // failure, and the MCP server reserves its own stderr for diagnostics.
51
+ return execFileSync("git", ["-C", repoRoot, ...args], {
52
+ encoding: "utf8",
53
+ maxBuffer: 64 * 1024 * 1024,
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ });
56
+ }
57
+
58
+ // First line of the most useful diagnostic from a failed git call.
59
+ function gitMsg(e) {
60
+ const s = (e && e.stderr ? String(e.stderr) : e && e.message ? e.message : String(e)).trim();
61
+ return s.split("\n")[0] || "git error";
62
+ }
63
+
64
+ // Assert repoRoot is inside a git work tree, distinguishing "git not installed"
65
+ // (ENOENT) from "not a git repo" so the message is actionable.
66
+ export function assertGitRepo(repoRoot) {
67
+ let out;
68
+ try {
69
+ out = git(repoRoot, ["rev-parse", "--is-inside-work-tree"]).trim();
70
+ } catch (e) {
71
+ if (e && e.code === "ENOENT") throw new HistoryError("git is not available on PATH", "git_unavailable");
72
+ throw new HistoryError(`not a git work tree: ${repoRoot}`, "not_a_git_repo");
73
+ }
74
+ if (out !== "true") throw new HistoryError(`not a git work tree: ${repoRoot}`, "not_a_git_repo");
75
+ }
76
+
77
+ // True when HEAD resolves to a commit. A freshly `git init`-ed repo (unborn HEAD)
78
+ // has no history yet, so `git log` would error rather than return empty; callers
79
+ // short-circuit to `[]` instead.
80
+ function hasHead(repoRoot) {
81
+ try {
82
+ git(repoRoot, ["rev-parse", "--verify", "--quiet", "HEAD"]);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ // Escape regex metacharacters so a configured idPrefix is matched literally — a
90
+ // prefix like `T+` must mean two characters, not "one or more T". Mirrors
91
+ // src/pr-title.mjs. (The core validator's own id regexes in src/backlog.mjs and
92
+ // src/mcp.mjs share this gap; hardening them is tracked separately.)
93
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+
95
+ function idRegex(cfg) {
96
+ return new RegExp(`^${escapeRe(cfg.idPrefix)}\\d{${cfg.idWidth}}$`);
97
+ }
98
+
99
+ // Resolve an id to its current file by probing the three status dirs, newest status
100
+ // last (active wins a hypothetical duplicate, which the generator flags separately).
101
+ // Validates the id format BEFORE building any path, so a crafted id (e.g.
102
+ // "../../etc/passwd") cannot escape the task tree. Returns { id, status, file } or null.
103
+ export function resolveTaskFile(repoRoot, cfg, id) {
104
+ if (typeof id !== "string" || !idRegex(cfg).test(id.trim())) {
105
+ throw new HistoryError(`invalid task id: ${id} (expected ${cfg.idPrefix} + ${cfg.idWidth} digits)`, "invalid_request");
106
+ }
107
+ const tid = id.trim();
108
+ const p = paths(repoRoot, cfg);
109
+ for (const [status, dir] of [["active", p.active], ["completed", p.completedTasks], ["removed", p.removed]]) {
110
+ const file = join(dir, `${tid}.md`);
111
+ if (existsSync(file)) return { id: tid, status, file };
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // Every task id currently on disk, scanned straight from the three status dirs
117
+ // (not readBacklog) so history works as a forensic tool even on a backlog that
118
+ // doesn't fully validate. Sorted by id for a stable key order in history.json.
119
+ export function taskIds(repoRoot, cfg) {
120
+ const p = paths(repoRoot, cfg);
121
+ const fileRe = new RegExp(`^(${escapeRe(cfg.idPrefix)}\\d{${cfg.idWidth}})\\.md$`);
122
+ const out = [];
123
+ for (const [status, dir] of [["active", p.active], ["completed", p.completedTasks], ["removed", p.removed]]) {
124
+ if (!existsSync(dir)) continue;
125
+ for (const f of readdirSync(dir)) {
126
+ const m = f.match(fileRe);
127
+ if (m) out.push({ id: m[1], status, file: join(dir, f) });
128
+ }
129
+ }
130
+ return out.sort((a, b) => a.id.localeCompare(b.id));
131
+ }
132
+
133
+ // Parse the delimited `git log` output into entries. `reason` is the Trellis-Reason
134
+ // trailer when present, else the subject — so it is ALWAYS populated and a consumer
135
+ // reads one field. Each entry carries its own id, matching the structured-entry
136
+ // contract shared by the CLI and the MCP tool.
137
+ function parseLog(out, id) {
138
+ return out
139
+ .split(RECORD)
140
+ .map((r) => r.trim()) // strip the inter-record newline git inserts between entries
141
+ .filter(Boolean)
142
+ .map((rec) => {
143
+ const [commit = "", date = "", author = "", subject = "", reasonTrailer = ""] = rec.split(FIELD);
144
+ const reason = reasonTrailer.trim() || subject.trim();
145
+ return { id, commit: commit.trim(), date: date.trim(), author: author.trim(), subject: subject.trim(), reason };
146
+ });
147
+ }
148
+
149
+ // `git log --follow` over one task file → entries newest-first. A path that was
150
+ // never committed (e.g. a brand-new, unstaged item) yields no output → `[]`.
151
+ function logFollow(repoRoot, id, relPath) {
152
+ let out;
153
+ try {
154
+ out = git(repoRoot, ["log", "--follow", `--format=${FORMAT}`, "--", relPath]);
155
+ } catch (e) {
156
+ throw new HistoryError(`git log failed for ${relPath}: ${gitMsg(e)}`, "git_failed");
157
+ }
158
+ return parseLog(out, id);
159
+ }
160
+
161
+ // One task's history → { id, entries }, newest-first. Throws HistoryError for a
162
+ // non-git repo, an invalid id, or an unknown id.
163
+ export function deriveTaskHistory(repoRoot, cfg, id) {
164
+ assertGitRepo(repoRoot);
165
+ const resolved = resolveTaskFile(repoRoot, cfg, id);
166
+ if (!resolved) throw new HistoryError(`no task file for id ${id}`, "not_found");
167
+ const entries = hasHead(repoRoot) ? logFollow(repoRoot, resolved.id, relative(repoRoot, resolved.file)) : [];
168
+ return { id: resolved.id, entries };
169
+ }
170
+
171
+ // Whole-repo history → { generated: true, tasks: { <id>: [entries…] } }, keyed by
172
+ // task id (every id on disk, `[]` if uncommitted). `generated: true` marks the file
173
+ // as a regenerable cache if it is ever committed despite being gitignored. One
174
+ // `git log --follow` per task file — `--follow` permits a single pathspec, so this
175
+ // cannot be one batched call; fine at backlog scale.
176
+ export function deriveAllHistory(repoRoot, cfg) {
177
+ assertGitRepo(repoRoot);
178
+ const head = hasHead(repoRoot);
179
+ const tasks = {};
180
+ for (const { id, file } of taskIds(repoRoot, cfg)) {
181
+ tasks[id] = head ? logFollow(repoRoot, id, relative(repoRoot, file)) : [];
182
+ }
183
+ return { generated: true, tasks };
184
+ }
185
+
186
+ // The history.json bytes — pretty-printed with a trailing newline, matching the
187
+ // backlog.json serializer's style.
188
+ export function buildHistoryJson(repoRoot, cfg) {
189
+ return JSON.stringify(deriveAllHistory(repoRoot, cfg), null, 2) + "\n";
190
+ }
191
+
192
+ // Materialize history.json to disk (the build-time / CI use). Writes to
193
+ // `<tasksDir>/history.json` by default, or `out` (repo-relative or absolute).
194
+ // Returns a small summary for the CLI to report.
195
+ export function materializeHistory(repoRoot, cfg, { out } = {}) {
196
+ const content = buildHistoryJson(repoRoot, cfg);
197
+ const target = out ? (isAbsolute(out) ? out : join(repoRoot, out)) : paths(repoRoot, cfg).historyJson;
198
+ mkdirSync(dirname(target), { recursive: true });
199
+ writeFileSync(target, content);
200
+ const parsed = JSON.parse(content);
201
+ const taskCount = Object.keys(parsed.tasks).length;
202
+ const entryCount = Object.values(parsed.tasks).reduce((n, e) => n + e.length, 0);
203
+ return { path: relative(repoRoot, target), taskCount, entryCount, bytes: Buffer.byteLength(content) };
204
+ }