@miroshnik/elves 0.0.0 → 2026.1018.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.
Files changed (68) hide show
  1. package/AGENTS.md +310 -0
  2. package/README.md +344 -0
  3. package/bin/ai-dev.ts +7 -0
  4. package/bin/elves.ts +2847 -0
  5. package/bin/shims/timeout +113 -0
  6. package/claude/CLAUDE.md +71 -0
  7. package/dist/github/scripts/github.js +3428 -0
  8. package/dist/spec/scripts/architecture.js +628 -0
  9. package/dist/spec/scripts/eslint-config.js +54 -0
  10. package/dist/spec/scripts/harness-qzb932c8.js +959 -0
  11. package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
  12. package/dist/spec/scripts/harness.js +53 -0
  13. package/dist/spec/scripts/knip-hints.cjs +34 -0
  14. package/docs/ci-concurrency.md +82 -0
  15. package/docs/cloud-sessions.md +203 -0
  16. package/docs/judgment.md +81 -0
  17. package/docs/parallel-checkouts.md +220 -0
  18. package/docs/pr-checks.md +117 -0
  19. package/docs/testing.md +304 -0
  20. package/package.json +70 -1
  21. package/skills/ci-wait/SKILL.md +180 -0
  22. package/skills/ci-wait/scripts/wait-ci.sh +377 -0
  23. package/skills/dashboard/SKILL.md +212 -0
  24. package/skills/dashboard/scripts/dashboard.ts +912 -0
  25. package/skills/dashboard/scripts/sessions.ts +772 -0
  26. package/skills/est/SKILL.md +221 -0
  27. package/skills/est/reference.md +381 -0
  28. package/skills/est/scripts/est.ts +4854 -0
  29. package/skills/github/SKILL.md +206 -0
  30. package/skills/github/locales/en.json +449 -0
  31. package/skills/github/locales/ru.json +449 -0
  32. package/skills/github/reference.md +1073 -0
  33. package/skills/github/scripts/github.ts +4390 -0
  34. package/skills/github/scripts/locale.ts +495 -0
  35. package/skills/github/scripts/package.json +3 -0
  36. package/skills/slot/SKILL.md +145 -0
  37. package/skills/slot/scripts/package.json +3 -0
  38. package/skills/slot/scripts/slot.ts +279 -0
  39. package/skills/spec/SKILL.md +247 -0
  40. package/skills/spec/canon.md +219 -0
  41. package/skills/spec/checks.md +709 -0
  42. package/skills/spec/ci.md +378 -0
  43. package/skills/spec/migration.md +125 -0
  44. package/skills/spec/reference.md +889 -0
  45. package/skills/spec/scripts/architecture.ts +839 -0
  46. package/skills/spec/scripts/c4.ts +184 -0
  47. package/skills/spec/scripts/eslint-config.ts +82 -0
  48. package/skills/spec/scripts/harness.ts +1483 -0
  49. package/skills/spec/scripts/knip-hints.cjs +34 -0
  50. package/skills/spec/scripts/knip-hints.d.cts +17 -0
  51. package/skills/spec/scripts/package-run.ts +22 -0
  52. package/skills/spec/scripts/package.json +3 -0
  53. package/skills/spec/scripts/spec-break.ts +232 -0
  54. package/skills/spec/scripts/spec-claims.ts +198 -0
  55. package/skills/spec/scripts/spec-diff.ts +1111 -0
  56. package/skills/spec/scripts/spec-doc.ts +744 -0
  57. package/skills/spec/scripts/spec-exceptions.ts +233 -0
  58. package/skills/spec/scripts/spec-publish.ts +214 -0
  59. package/skills/spec/scripts/spec-run.ts +235 -0
  60. package/skills/spec/scripts/spec-shard.ts +267 -0
  61. package/skills/spec/scripts/speclib.ts +826 -0
  62. package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
  63. package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
  64. package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
  65. package/skills/task-env/SKILL.md +231 -0
  66. package/skills/task-env/proxy/compose.yaml +51 -0
  67. package/skills/task-env/reference.md +254 -0
  68. package/skills/task-env/scripts/task-env.ts +1017 -0
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * spec-exceptions — moving exceptions to the "file per item" format: each `exceptions.ts` (`.mts`, `.js`, `.mjs`,
4
+ * `.json`) of a decision folder `tests/<kind>/<name>` or its subfolder is split into the decision folder's
5
+ * `exceptions/` directory — a `<item>.json` file per exception (`{ item, rule?, issue, reason }`), the old file is
6
+ * deleted; which file belongs where — the `exceptionFile` rule (speclib). The import in the folder's files
7
+ * (`import exceptions from "./exceptions.ts"`) becomes the harness's `const exceptions = exceptionsIn()`, the path to the
8
+ * old file in `package.json` and the CI workflow (`spec-claims --exceptions`) — the path to the directory. What can't be
9
+ * rewritten (a reference from another folder, no harness import) — a `!` line. A repeated run changes nothing.
10
+ *
11
+ * bun spec-exceptions.ts [--root DIR]
12
+ *
13
+ * Codes: 0 — moved or nothing to move, 1 — something is left to fix by hand, 2 — a call error or a bad exceptions file.
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import path from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import { parseArgs } from "node:util";
20
+
21
+ import { EXCEPTIONS_DIR, exceptionFile, TESTS } from "./speclib.ts";
22
+
23
+ const USAGE = "spec-exceptions [--root DIR]";
24
+ /** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples; `elves` prints it under its name. */
25
+ export const HELP = `${USAGE}
26
+
27
+ Moves exceptions to the "file per item" format: each exceptions.ts of a decision folder — into its exceptions/ directory,
28
+ a file per exception; a repeated run changes nothing. Code 0 — moved or nothing to move, 1 — something is left to fix by
29
+ hand, 2 — a call error.
30
+
31
+ Examples:
32
+ spec-exceptions
33
+ spec-exceptions --root packages/api`;
34
+ const CODE = /\.[cm]?[jt]sx?$/;
35
+ const SKIP_DIRS = new Set(["node_modules", ".git", EXCEPTIONS_DIR]);
36
+
37
+ interface Entry {
38
+ item: string;
39
+ rule?: string;
40
+ issue: number;
41
+ reason: string;
42
+ }
43
+
44
+ /** Files under the directory (from the project root), in order; exception and dependency directories are skipped. */
45
+ function walk(root: string, rel: string): string[] {
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(path.join(root, rel), { withFileTypes: true });
49
+ } catch {
50
+ return [];
51
+ }
52
+ return entries
53
+ .flatMap((e) => (e.isDirectory() ? (SKIP_DIRS.has(e.name) ? [] : walk(root, `${rel}/${e.name}`)) : [`${rel}/${e.name}`]))
54
+ .sort();
55
+ }
56
+
57
+ /** The exceptions list from the former file: JSON — parsed, a module — `default` (or `exceptions`). */
58
+ async function loadLegacy(abs: string): Promise<unknown> {
59
+ if (abs.endsWith(".json")) return JSON.parse(readFileSync(abs, "utf8"));
60
+ const mod = (await import(pathToFileURL(abs).href)) as { default?: unknown; exceptions?: unknown };
61
+ return mod.default ?? mod.exceptions;
62
+ }
63
+
64
+ /** The exception file's name is the item (and the convention) in Latin letters: stable, visible in the tree and in the diff. */
65
+ const slug = (e: Entry): string =>
66
+ ((e.rule ? `${e.rule}--` : "") + e.item)
67
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
68
+ .replace(/^[-.]+|-+$/g, "")
69
+ .slice(0, 100) || "exception";
70
+
71
+ const json = (e: Entry): string =>
72
+ JSON.stringify({ item: e.item, ...(e.rule ? { rule: e.rule } : {}), issue: e.issue, reason: e.reason }, null, 2) + "\n";
73
+
74
+ // import x from "./exceptions.ts" · import { exceptions } from … · import { exceptions as x } from … (+ with { type })
75
+ const IMPORT = /^import\s+(?:(\w+)|\{\s*(\w+)(?:\s+as\s+(\w+))?\s*\})\s+from\s+(["'])([^"']+)\4(?:\s+with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm;
76
+ const ANY_IMPORT = /(?:\bfrom\s*|\bimport\s*\(?\s*)(["'])([^"']+)\1/g;
77
+ const HARNESS = /^import\s*\{([^}]*)\}\s*from\s*(["'])([^"']*\/harness(?:\.ts)?)\2/m;
78
+ const HARNESS_TYPE = /^import\s+type\s*\{[^}]*\}\s*from\s*(["'])([^"']*\/harness(?:\.ts)?)\1\s*;?[ \t]*\n/m;
79
+
80
+ /** The import path from the file points to the former exceptions file (with or without the extension). */
81
+ function pointsTo(fromFile: string, spec: string, legacy: string): boolean {
82
+ if (!spec.startsWith(".")) return false;
83
+ const target = path.posix.join(path.posix.dirname(fromFile), spec);
84
+ return target === legacy || target === legacy.replace(/\.[^./]+$/, "");
85
+ }
86
+
87
+ /** The end of the last import statement at the start of the file — after its line. */
88
+ function afterImports(text: string): number {
89
+ let end = 0;
90
+ let open = false;
91
+ let at = 0;
92
+ for (const line of text.split("\n")) {
93
+ const next = at + line.length + 1;
94
+ if (!open && /^import\b/.test(line)) open = true;
95
+ if (open && (/\bfrom\s*["'][^"']+["']/.test(line) || /^import\s*["'][^"']+["']/.test(line))) {
96
+ open = false;
97
+ end = Math.min(next, text.length);
98
+ }
99
+ at = next;
100
+ }
101
+ return end;
102
+ }
103
+
104
+ /** Add `exceptionsIn` to the harness import: to the value list, otherwise next to the type import; neither — null. */
105
+ function withHarnessImport(text: string): string | null {
106
+ const m = HARNESS.exec(text);
107
+ if (m) {
108
+ const names = m[1]!.split(",").map((x) => x.trim()).filter(Boolean);
109
+ if (names.includes("exceptionsIn")) return text;
110
+ const sorted = names.every((x, i) => i === 0 || names[i - 1]! <= x);
111
+ const all = sorted ? [...names, "exceptionsIn"].sort() : [...names, "exceptionsIn"];
112
+ const indent = /\n([ \t]+)\S/.exec(m[1]!)?.[1];
113
+ const list = indent !== undefined ? `{\n${all.map((x) => `${indent}${x},`).join("\n")}\n}` : `{ ${all.join(", ")} }`;
114
+ return text.slice(0, m.index) + `import ${list} from ${m[2]}${m[3]}${m[2]}` + text.slice(m.index + m[0].length);
115
+ }
116
+ const t = HARNESS_TYPE.exec(text);
117
+ if (t) {
118
+ const at = t.index + t[0].length;
119
+ return text.slice(0, at) + `import { exceptionsIn } from ${t[1]}${t[2]}${t[1]};\n` + text.slice(at);
120
+ }
121
+ return null;
122
+ }
123
+
124
+ export async function main(argv: string[]): Promise<number> {
125
+ let values;
126
+ try {
127
+ ({ values } = parseArgs({ args: argv, options: { root: { type: "string" }, help: { type: "boolean", short: "h", default: false } } }));
128
+ } catch (e) {
129
+ console.error(`spec-exceptions: ${(e as Error).message}\n${USAGE}`);
130
+ return 2;
131
+ }
132
+ if (values.help) return console.log(HELP), 0;
133
+ const root = path.resolve(values.root ?? ".");
134
+ const files = walk(root, TESTS);
135
+ // former files — by the exceptionFile rule: in the decision folder or its subfolder, they move to the decision folder's directory
136
+ const legacyOf = (f: string) => {
137
+ const x = exceptionFile(f);
138
+ return x?.legacy ? x : null;
139
+ };
140
+ const legacy = files.filter((f) => legacyOf(f) !== null);
141
+ const configs = ["package.json", ...walk(root, ".github/workflows").filter((f) => /\.ya?ml$/.test(f))].filter((f) => existsSync(path.join(root, f)));
142
+ const out: string[] = [];
143
+ const manual: string[] = [];
144
+ const moved: string[] = [];
145
+
146
+ for (const rel of legacy) {
147
+ let list: unknown;
148
+ try {
149
+ list = await loadLegacy(path.join(root, rel));
150
+ } catch (e) {
151
+ console.error(`spec-exceptions: ${rel} doesn't load: ${(e as Error).message}`);
152
+ return 2;
153
+ }
154
+ if (!Array.isArray(list)) {
155
+ console.error(`spec-exceptions: ${rel} — export default [{ item, issue, reason }] is needed`);
156
+ return 2;
157
+ }
158
+ const bad = list.findIndex((x) => !x || typeof x !== "object" || typeof x.item !== "string");
159
+ if (bad >= 0) {
160
+ console.error(`spec-exceptions: ${rel}: entry ${bad + 1} is not an exception { item, issue, reason }`);
161
+ return 2;
162
+ }
163
+ const folder = legacyOf(rel)!.folder;
164
+ const dir = `${folder}/${EXCEPTIONS_DIR}`;
165
+ const written: string[] = [];
166
+ for (const e of list as Entry[]) {
167
+ const body = json(e);
168
+ let name = slug(e);
169
+ // the name is taken by another exception — a suffix; the same text — already moved
170
+ for (let n = 2; existsSync(path.join(root, dir, `${name}.json`)) && readFileSync(path.join(root, dir, `${name}.json`), "utf8") !== body; n++) {
171
+ name = `${slug(e)}-${n}`;
172
+ }
173
+ mkdirSync(path.join(root, dir), { recursive: true });
174
+ writeFileSync(path.join(root, dir, `${name}.json`), body);
175
+ written.push(`${dir}/${name}.json`);
176
+ }
177
+ rmSync(path.join(root, rel));
178
+ moved.push(rel);
179
+ out.push(`- ${rel} → ${dir}/ (${written.length})`, ...written.map((w) => `+ ${w}`));
180
+
181
+ // the import in the files of the folder and its subfolders — to exceptionsIn(): the harness finds the directory of the test's decision folder itself
182
+ for (const file of files.filter((f) => f.startsWith(`${folder}/`) && CODE.test(f) && f !== rel)) {
183
+ const text = readFileSync(path.join(root, file), "utf8");
184
+ const names: string[] = [];
185
+ const rest = text.replace(IMPORT, (all, def: string | undefined, named: string | undefined, alias: string | undefined, _q: string, spec: string) => {
186
+ if (!pointsTo(file, spec, rel)) return all;
187
+ if (named !== undefined && named !== "exceptions" && named !== "default") return all;
188
+ names.push(def ?? alias ?? named!);
189
+ return "";
190
+ });
191
+ const name = names[0];
192
+ if (name === undefined) continue;
193
+ const at = afterImports(rest);
194
+ const next = withHarnessImport(rest.slice(0, at) + `\nconst ${name} = exceptionsIn();\n` + rest.slice(at));
195
+ if (next === null) {
196
+ manual.push(`! ${file}: the import of ${rel} isn't replaced — no harness import, add import { exceptionsIn } and const ${name} = exceptionsIn()`);
197
+ continue;
198
+ }
199
+ writeFileSync(path.join(root, file), next);
200
+ out.push(`~ ${file}: import → exceptionsIn()`);
201
+ }
202
+ }
203
+
204
+ // the path to the former file in the project's scripts and CI (spec-claims --exceptions) — by the path to the directory
205
+ for (const file of configs) {
206
+ let text = readFileSync(path.join(root, file), "utf8");
207
+ for (const rel of moved) {
208
+ const re = new RegExp(`(?<![\\w./-])${rel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`, "g");
209
+ if (!re.test(text)) continue;
210
+ const dir = `${legacyOf(rel)!.folder}/${EXCEPTIONS_DIR}`;
211
+ text = text.replace(re, dir);
212
+ out.push(`~ ${file}: ${rel} → ${dir}`);
213
+ }
214
+ if (text !== readFileSync(path.join(root, file), "utf8")) writeFileSync(path.join(root, file), text);
215
+ }
216
+
217
+ // references to the moved files that the command didn't rewrite — by hand
218
+ for (const file of walk(root, TESTS).filter((f) => CODE.test(f))) {
219
+ const text = readFileSync(path.join(root, file), "utf8");
220
+ for (const m of text.matchAll(ANY_IMPORT)) {
221
+ const rel = moved.find((l) => pointsTo(file, m[2]!, l));
222
+ if (rel) manual.push(`! ${file}: a reference to ${rel} — replace it with the harness's exceptionsIn("${legacyOf(rel)!.folder}")`);
223
+ }
224
+ }
225
+
226
+ if (!moved.length) out.push("no exceptions.ts in decision folders — nothing to move");
227
+ for (const l of [...out, ...manual]) console.log(l);
228
+ return manual.length ? 1 : 0;
229
+ }
230
+
231
+ if (import.meta.main) {
232
+ process.exitCode = await main(process.argv.slice(2));
233
+ }
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * spec-publish — publishing the generated documentation (docs/spec) to a separate branch (spec): the branch root is the
4
+ * directory's contents, the commit message names the SHA it was built from. The documentation isn't committed to main:
5
+ * it is a derived copy of the tests, and parallel PRs would conflict in its pages and table of contents.
6
+ *
7
+ * The working copy and HEAD aren't touched: the tree is built in a temporary index, the commit — `git commit-tree` on top
8
+ * of the published one, the push — `<commit>:refs/heads/<branch>`. The same source — no new commit; the same contents
9
+ * from a new source — a commit with the same tree and a new `Source:` (the branch names the last checked main); the
10
+ * published `Source:` is a descendant of the new source — "already newer", the branch isn't touched. A rejected push (the
11
+ * branch moved by a parallel publish, a hosting failure) — a pause of `--interval` × the attempt number, the branch is
12
+ * re-read and the decision is made again, up to `--attempts` attempts. After the push the branch is read from the remote
13
+ * and compared with the directory — exactly what was built is published; a later publish already on top of our commit
14
+ * isn't a mismatch. `--check` — comparison only.
15
+ *
16
+ * bun spec-publish.ts [--dir docs/spec] [--branch spec] [--remote origin] [--source <sha>] [--attempts 3] [--interval 5] [--check] [--root DIR]
17
+ *
18
+ * Codes: 0 — published, matches or already newer, 1 — the branch differs from the directory (or doesn't exist) with
19
+ * --check, the push was rejected on all attempts, 2 — a call error (no directory, not git). Runs under Bun, no
20
+ * dependencies.
21
+ */
22
+
23
+ import { execFileSync } from "node:child_process";
24
+ import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { parseArgs } from "node:util";
28
+
29
+ import { sourceOf } from "./speclib.ts";
30
+
31
+ const USAGE =
32
+ "spec-publish [--dir docs/spec] [--branch spec] [--remote origin] [--source <sha>] [--attempts 3] [--interval 5] [--check] [--root DIR]";
33
+ /** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples; `elves` prints it under its name. */
34
+ export const HELP = `${USAGE}
35
+
36
+ Publishes the generated documentation (docs/spec) to the spec branch without touching the working copy; the commit
37
+ names the source SHA. Code 0 — published, matches or already newer, 1 — differs with --check or the push was rejected on
38
+ all attempts, 2 — a call error.
39
+
40
+ Examples:
41
+ spec-publish --source "$GITHUB_SHA"
42
+ spec-publish --check`;
43
+
44
+ // a publish commit without a configured git user (CI) — in the name of the GitHub Actions bot
45
+ const BOT = { name: "github-actions[bot]", email: "41898282+github-actions[bot]@users.noreply.github.com" };
46
+
47
+ class Fail extends Error {
48
+ code: number;
49
+ constructor(message: string, code: number) {
50
+ super(message);
51
+ this.code = code;
52
+ }
53
+ }
54
+
55
+ function git(root: string, args: string[], env: Record<string, string> = {}): string {
56
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } }).trim();
57
+ }
58
+
59
+ function tryGit(root: string, args: string[]): string | null {
60
+ try {
61
+ return git(root, args);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /** The directory's tree as a git object: a temporary index, --work-tree = the directory; the repository's .gitignore doesn't interfere (-f). */
68
+ function treeOf(root: string, dir: string): string {
69
+ const tmp = mkdtempSync(path.join(os.tmpdir(), "spec-publish-"));
70
+ try {
71
+ const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
72
+ // cwd is the directory itself: index paths from its root; git-dir is the repository's (in a worktree — its own, objects shared)
73
+ const gitDir = git(root, ["rev-parse", "--absolute-git-dir"]);
74
+ git(dir, ["--git-dir", gitDir, "--work-tree", dir, "add", "-A", "-f", "."], env);
75
+ return git(dir, ["--git-dir", gitDir, "write-tree"], env);
76
+ } finally {
77
+ rmSync(tmp, { recursive: true, force: true });
78
+ }
79
+ }
80
+
81
+ /** The branch's published commit on the remote (after fetch) or null if there is no branch. */
82
+ function remoteHead(root: string, remote: string, branch: string): string | null {
83
+ if (!tryGit(root, ["ls-remote", "--exit-code", "--heads", remote, branch])) return null;
84
+ git(root, ["fetch", "-q", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]);
85
+ return git(root, ["rev-parse", `refs/remotes/${remote}/${branch}`]);
86
+ }
87
+
88
+ /** Paths by which the branch's tree differs from the directory's tree. */
89
+ function diffPaths(root: string, a: string, b: string): string[] {
90
+ const out = git(root, ["diff-tree", "-r", "--name-only", "--no-commit-id", a, b]);
91
+ return out ? out.split("\n") : [];
92
+ }
93
+
94
+ /** Why git refused: the last stderr lines without hints — the first error line names only the command. */
95
+ function reason(e: unknown): string {
96
+ const stderr = String((e as { stderr?: unknown }).stderr ?? "");
97
+ const lines = stderr.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("hint:"));
98
+ return lines.length ? lines.slice(-3).join("; ") : (e as Error).message.split("\n")[0]!;
99
+ }
100
+
101
+ function count(name: string, value: string, min: number): number {
102
+ const n = Number(value);
103
+ if (!Number.isInteger(n) || n < min) throw new Fail(`--${name}: expected an integer ≥ ${min}, got «${value}»`, 2);
104
+ return n;
105
+ }
106
+
107
+ function identity(root: string): Record<string, string> {
108
+ const name = tryGit(root, ["config", "user.name"]) || BOT.name;
109
+ const email = tryGit(root, ["config", "user.email"]) || BOT.email;
110
+ return { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email };
111
+ }
112
+
113
+ export async function main(argv: string[]): Promise<number> {
114
+ let opts;
115
+ try {
116
+ opts = parseArgs({
117
+ args: argv,
118
+ options: {
119
+ dir: { type: "string", default: path.join("docs", "spec") },
120
+ branch: { type: "string", default: "spec" },
121
+ remote: { type: "string", default: "origin" },
122
+ source: { type: "string" },
123
+ attempts: { type: "string", default: "3" },
124
+ interval: { type: "string", default: "5" },
125
+ check: { type: "boolean", default: false },
126
+ root: { type: "string" },
127
+ help: { type: "boolean", short: "h", default: false },
128
+ },
129
+ });
130
+ } catch (e) {
131
+ console.error(`spec-publish: ${(e as Error).message}\n${USAGE}`);
132
+ return 2;
133
+ }
134
+ const v = opts.values;
135
+ if (v.help) return console.log(HELP), 0;
136
+ try {
137
+ const root = v.root ? path.resolve(v.root) : git(process.cwd(), ["rev-parse", "--show-toplevel"]);
138
+ const dir = path.resolve(root, v.dir);
139
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
140
+ throw new Fail(`no directory ${v.dir} — build the documentation first: spec-doc <report> --out ${v.dir}`, 2);
141
+ }
142
+ const attempts = count("attempts", v.attempts, 1);
143
+ const interval = count("interval", v.interval, 0);
144
+ const tree = treeOf(root, dir);
145
+
146
+ if (v.check) {
147
+ const head = remoteHead(root, v.remote, v.branch);
148
+ if (!head) throw new Fail(`no branch ${v.branch} on ${v.remote} — the documentation isn't published`, 1);
149
+ const published = git(root, ["rev-parse", `${head}^{tree}`]);
150
+ if (published !== tree) throw new Fail(`${v.remote}/${v.branch} differs: ${diffPaths(root, published, tree).join(", ")}`, 1);
151
+ console.error(`spec-publish: ${v.remote}/${v.branch} matches ${v.dir}`);
152
+ return 0;
153
+ }
154
+
155
+ const source = v.source ?? git(root, ["rev-parse", "HEAD"]);
156
+ const sha = tryGit(root, ["rev-parse", "--verify", "-q", `${source}^{commit}`]);
157
+ const message = `spec: ${source.slice(0, 12)}\n\nSource: ${source}\n`;
158
+ let same = false;
159
+ let commit = "";
160
+ // a push is rejected when the branch moved between fetch and push or on a hosting failure: each attempt re-reads the
161
+ // branch and decides again — after a move it may turn out to be built from a descendant
162
+ for (let attempt = 1; ; attempt++) {
163
+ const head = remoteHead(root, v.remote, v.branch);
164
+ const published = head ? git(root, ["rev-parse", `${head}^{tree}`]) : null;
165
+ // publishes don't arrive in merge order (main runs are parallel): an old one isn't published on top of one built
166
+ // from a descendant. Comparing needs history: in a shallow clone the published source is unknown — it is published
167
+ const was = head ? sourceOf(git(root, ["log", "-1", "--format=%B", head])) : undefined;
168
+ if (was && sha && sha !== was && tryGit(root, ["merge-base", "--is-ancestor", sha, was]) !== null) {
169
+ console.error(`spec-publish: skipped — ${v.remote}/${v.branch} already newer: published from ${was.slice(0, 12)}`);
170
+ return 0;
171
+ }
172
+ same = published === tree;
173
+ if (same && (sha ?? source) === was) {
174
+ console.error(`spec-publish: no changes — ${v.remote}/${v.branch} already matches ${v.dir}`);
175
+ return 0;
176
+ }
177
+ // the same tree from a new source is a commit too: `Source:` names the last checked main, and spec-diff sees a gap
178
+ // with the diff base only after a skipped publish, not after every merge without spec changes
179
+ commit = git(root, ["commit-tree", tree, ...(head ? ["-p", head] : []), "-m", message], identity(root));
180
+ try {
181
+ git(root, ["push", "-q", v.remote, `${commit}:refs/heads/${v.branch}`]);
182
+ break;
183
+ } catch (e) {
184
+ const rejected = `push to ${v.remote}/${v.branch} rejected (attempt ${attempt} of ${attempts}): ${reason(e)}`;
185
+ if (attempt >= attempts) throw new Fail(rejected, 1);
186
+ console.error(`spec-publish: ${rejected} — re-reading the branch, retrying in ${interval * attempt} s`);
187
+ await new Promise((resolve) => setTimeout(resolve, interval * attempt * 1000));
188
+ }
189
+ }
190
+ // check what reached the reader: the branch on the remote, not our own commit. Between the push and this read the
191
+ // next publish may have landed on top (main runs are parallel): our commit in the branch's history is published —
192
+ // only a publish not older goes on top, an older one is skipped as "already newer"
193
+ const after = remoteHead(root, v.remote, v.branch);
194
+ const moved = after !== null && git(root, ["rev-parse", `${after}^{tree}`]) !== tree;
195
+ if (!after || (moved && tryGit(root, ["merge-base", "--is-ancestor", commit, after]) === null)) {
196
+ throw new Fail(`after the push ${v.remote}/${v.branch} doesn't match ${v.dir}`, 1);
197
+ }
198
+ if (same) console.error(`spec-publish: no changes — ${v.remote}/${v.branch} confirmed for ${source.slice(0, 12)}`);
199
+ else console.error(`spec-publish: published ${v.dir} → ${v.remote}/${v.branch} ${commit.slice(0, 12)} (from ${source.slice(0, 12)})`);
200
+ if (moved) console.error(`spec-publish: ${v.remote}/${v.branch} has moved on since — a later publish on top: ${after.slice(0, 12)}`);
201
+ return 0;
202
+ } catch (e) {
203
+ if (e instanceof Fail) {
204
+ console.error(`spec-publish: ${e.message}`);
205
+ return e.code;
206
+ }
207
+ console.error(`spec-publish: ${(e as Error).message}`);
208
+ return 2;
209
+ }
210
+ }
211
+
212
+ if (import.meta.main) {
213
+ process.exitCode = await main(process.argv.slice(2));
214
+ }