@codedology/code-forge 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.
Files changed (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The fix-loop state per `(run, block, file)` (plan §4.11; block B12c).
3
+ *
4
+ * B12b's `fixloop.mjs` is a state machine whose `FileState` (round, level, open findings,
5
+ * `reviewed_content`, …) must survive between two `review-file` calls on the same file: round 1
6
+ * is the first ticket, round 2 (the fix-hunk recheck) the next one, and so on. The worker keeps it
7
+ * in the run's temp root, OUTSIDE the workspace:
8
+ *
9
+ * <run-root>/review-state/<block>/<sha256(file)[0:24]>.json
10
+ *
11
+ * Every state file is SIGNED with the run key (`{run, block, file, seq, state}` + `mac`) AND
12
+ * anchored in the run's append-only ledger: each save first writes a signed row
13
+ * `{event: 'review.state', block, file, seq, state_sha, l3_rung_used}`. A load matches the file
14
+ * against the LATEST verified anchor (highest `seq`) for the file: a missing file, an older signed
15
+ * file (rollback), a different sha, or a file with no anchor is `tampered` — the hook refuses the
16
+ * ticket, never a fresh round 1 that could clear a `review_cap` stop or an open finding. A
17
+ * `review.state` row of the run whose MAC does not verify is `tampered` for every block and file
18
+ * of it (`blockAnchors`). Whether the block's one L3 rung was used comes from the anchors, never
19
+ * from the state files.
20
+ */
21
+
22
+ import { createHash, randomBytes } from 'node:crypto';
23
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { signRow, verifyRow } from '../state/signer.mjs';
26
+ import { assertBlockId, assertRowPath } from './ticket.mjs';
27
+
28
+ /** @typedef {import('../review/fixloop.mjs').FileState} FileState */
29
+ /** @typedef {{runRootDir: string, runId: string, block: string, file: string, key: Buffer, rows: Array<Record<string, any>>}} Where */
30
+
31
+ export const STATE_EVENT = 'review.state';
32
+
33
+ /** @param {string} runRootDir @param {string} block @returns {string} */
34
+ export const blockStateDir = (runRootDir, block) => path.join(runRootDir, 'review-state', assertBlockId(block));
35
+
36
+ /**
37
+ * @param {string} runRootDir @param {string} block @param {string} file - repo-root-relative.
38
+ * @returns {string}
39
+ */
40
+ export function statePath(runRootDir, block, file) {
41
+ const name = createHash('sha256').update(assertRowPath(file)).digest('hex').slice(0, 24);
42
+ return path.join(blockStateDir(runRootDir, block), `${name}.json`);
43
+ }
44
+
45
+ /** @param {string} text @returns {string} */
46
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
47
+
48
+ /**
49
+ * The block's verified `review.state` anchors, or null when any anchor of the RUN fails: EVERY
50
+ * `review.state` row of the run is verified (MAC, integer `seq`, string `state_sha`) BEFORE the
51
+ * block filter, so an anchor whose `block` was edited does not silently drop out and let the
52
+ * previous seq's state file validate (a rollback) — it fails its MAC and the whole run is
53
+ * `tampered`. The run selector stays first because the ledger is per project: other runs' anchors
54
+ * are signed with their own keys and cannot verify with this one. (An anchor whose `run` was
55
+ * edited is, for this run, a deleted line: out of the MAC's reach, like any dropped ledger row.)
56
+ * @param {{runId: string, block: string, key: Buffer, rows: Array<Record<string, any>>}} opts
57
+ * @returns {Array<Record<string, any>> | null}
58
+ */
59
+ export function blockAnchors({ runId, block, key, rows }) {
60
+ const ofRun = rows.filter((r) => r?.event === STATE_EVENT && r.run === runId);
61
+ if (ofRun.some((r) => !verifyRow(r, key).ok || !Number.isInteger(r.seq) || typeof r.state_sha !== 'string')) return null;
62
+ return ofRun.filter((r) => r.block === block);
63
+ }
64
+
65
+ /**
66
+ * @param {Array<Record<string, any>>} anchors @param {string} file
67
+ * @returns {Record<string, any> | null} the file's latest anchor.
68
+ */
69
+ function latestAnchor(anchors, file) {
70
+ let best = null;
71
+ for (const a of anchors) if (a.file === file && (best === null || a.seq > best.seq)) best = a;
72
+ return best;
73
+ }
74
+
75
+ /**
76
+ * @param {Where} where
77
+ * @returns {{status: 'none' | 'ok' | 'tampered', state: FileState | null, seq: number}}
78
+ */
79
+ export function loadState({ runRootDir, runId, block, file, key, rows }) {
80
+ const anchors = blockAnchors({ runId, block, key, rows });
81
+ if (anchors === null) return { status: 'tampered', state: null, seq: 0 };
82
+ const anchor = latestAnchor(anchors, file);
83
+ let text = null;
84
+ try {
85
+ text = readFileSync(statePath(runRootDir, block, file), 'utf8');
86
+ } catch (err) {
87
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') return { status: 'tampered', state: null, seq: 0 };
88
+ }
89
+ if (anchor === null) return text === null ? { status: 'none', state: null, seq: 0 } : { status: 'tampered', state: null, seq: 0 };
90
+ if (text === null || sha(text) !== anchor.state_sha) return { status: 'tampered', state: null, seq: anchor.seq };
91
+ let body = null;
92
+ try {
93
+ body = JSON.parse(text);
94
+ } catch {
95
+ body = null;
96
+ }
97
+ const ok =
98
+ body &&
99
+ typeof body === 'object' &&
100
+ verifyRow(body, key).ok &&
101
+ body.run === runId &&
102
+ body.block === block &&
103
+ body.file === file &&
104
+ body.seq === anchor.seq &&
105
+ body.state &&
106
+ typeof body.state === 'object' &&
107
+ body.state.file === file;
108
+ if (!ok) return { status: 'tampered', state: null, seq: anchor.seq };
109
+ return { status: 'ok', state: /** @type {FileState} */ (body.state), seq: anchor.seq };
110
+ }
111
+
112
+ /**
113
+ * Anchor, then write: the signed `review.state` row goes to the ledger FIRST (a failed ledger write
114
+ * changes nothing and throws), then the state file is replaced atomically (mode 0600).
115
+ * @param {Where & {state: FileState, seq: number, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
116
+ * `seq` - the loaded state's seq (0 for a new file); this save is `seq + 1`.
117
+ */
118
+ export async function saveState({ runRootDir, runId, block, file, key, state, seq, writeRow }) {
119
+ const target = statePath(runRootDir, block, file);
120
+ const next = seq + 1;
121
+ const text = `${JSON.stringify(signRow({ run: runId, block, file, seq: next, state }, key))}\n`;
122
+ await writeRow({ event: STATE_EVENT, block, file, seq: next, state_sha: sha(text), l3_rung_used: state.l3_rung_used === true });
123
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
124
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
125
+ try {
126
+ writeFileSync(tmp, text, { mode: 0o600 });
127
+ renameSync(tmp, target);
128
+ } catch (err) {
129
+ rmSync(tmp, { force: true });
130
+ throw err;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Whether the block's one L3 rung (§3.6 rules 5/6) was already used on ANY of its files — the
136
+ * rung is once per block. Derived from the ledger anchors only; fails closed: a block anchor that
137
+ * does not verify is `tampered`.
138
+ * @param {{runId: string, block: string, key: Buffer, rows: Array<Record<string, any>>}} opts
139
+ * @returns {{status: 'ok' | 'tampered', used: boolean}}
140
+ */
141
+ export function blockRungUsed(opts) {
142
+ const anchors = blockAnchors(opts);
143
+ if (anchors === null) return { status: 'tampered', used: true };
144
+ return { status: 'ok', used: anchors.some((a) => a.l3_rung_used === true) };
145
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Review tickets (plan §4.8, V4; block B11): repo-root-relative paths, content hashes and ticket
3
+ * ids. Pure functions plus the one `git rev-parse --show-toplevel` call.
4
+ *
5
+ * Path rule (V4): a ticket's `file` — and every `review.*` row the worker writes — is relative to
6
+ * the REPOSITORY ROOT (realpath'd `git rev-parse --show-toplevel`), whatever directory the coder
7
+ * called `review-file` from. A raw argument that is absolute or has a `..` segment is refused with
8
+ * `bad-path` before anything is written; so is a result outside the root or under `.git/` or
9
+ * `.code-forge/`. A stored value is re-checked by `assertRowPath` (tickets are coder-writable).
10
+ *
11
+ * Ticket id = the first 24 hex chars of `sha256(run \0 block \0 file \0 content_hash)`: the same
12
+ * content enqueued twice is the same ticket (idempotent), an edited file is a new one.
13
+ */
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { exec } from '../util/exec.mjs';
19
+
20
+ /** A refusal with a stable machine-readable `code` (`bad-path`, `usage`, `no-repo`). */
21
+ export class WorkerError extends Error {
22
+ /** @param {string} code @param {string} message */
23
+ constructor(code, message) {
24
+ super(message);
25
+ this.name = 'WorkerError';
26
+ this.code = code;
27
+ }
28
+ }
29
+
30
+ /** The content hash recorded for a file that no longer exists (a deletion is reviewable). */
31
+ export const DELETED_HASH = 'deleted';
32
+
33
+ const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
34
+ const TICKET_ID = /^[0-9a-f]{24}$/;
35
+
36
+ /** @param {unknown} id @returns {string} */
37
+ export function assertBlockId(id) {
38
+ if (typeof id !== 'string' || !BLOCK_ID.test(id)) throw new WorkerError('usage', `block id must match ${BLOCK_ID}`);
39
+ return id;
40
+ }
41
+
42
+ /** @param {unknown} id @returns {string} */
43
+ export function assertTicketId(id) {
44
+ if (typeof id !== 'string' || !TICKET_ID.test(id)) throw new WorkerError('usage', 'a ticket id is 24 lowercase hex characters');
45
+ return id;
46
+ }
47
+
48
+ /**
49
+ * The env for a child `git`: no inherited repository-selecting `GIT_*` variable (a git hook
50
+ * exports `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, …), no system or global config.
51
+ * @param {NodeJS.ProcessEnv} [env]
52
+ * @returns {NodeJS.ProcessEnv}
53
+ */
54
+ export function gitChildEnv(env = process.env) {
55
+ /** @type {NodeJS.ProcessEnv} */
56
+ const out = {};
57
+ for (const [name, value] of Object.entries(env)) {
58
+ if (!name.startsWith('GIT_')) out[name] = value;
59
+ }
60
+ return { ...out, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0' };
61
+ }
62
+
63
+ /**
64
+ * The repository root containing `cwd`, realpath'd.
65
+ * @param {string} cwd
66
+ * @returns {Promise<string>}
67
+ * @throws {WorkerError} `no-repo`
68
+ */
69
+ export async function repoRootOf(cwd) {
70
+ const res = await exec(['git', 'rev-parse', '--show-toplevel'], { cwd, env: gitChildEnv(), timeoutMs: 10000 });
71
+ const top = res.stdout.trim();
72
+ if (res.result !== 'ok' || top.length === 0) throw new WorkerError('no-repo', 'not inside a git repository');
73
+ return realpathSync(top);
74
+ }
75
+
76
+ /**
77
+ * Refuse a stored row path that is not a plain repo-root-relative POSIX path (V4).
78
+ * @param {unknown} value
79
+ * @returns {string}
80
+ * @throws {WorkerError} `bad-path`
81
+ */
82
+ export function assertRowPath(value) {
83
+ if (typeof value !== 'string' || value.length === 0 || value.length > 4096) throw new WorkerError('bad-path', 'file must be a non-empty relative path');
84
+ if (value.includes('\0') || value.includes('\\')) throw new WorkerError('bad-path', 'file must be a POSIX path');
85
+ if (path.posix.isAbsolute(value)) throw new WorkerError('bad-path', 'file must be relative to the repository root, not absolute');
86
+ const segments = value.split('/');
87
+ if (segments.some((s) => s === '' || s === '.' || s === '..')) throw new WorkerError('bad-path', 'file must not contain "", "." or ".." segments');
88
+ const top = segments[0].toLowerCase(); // case-insensitive file systems (macOS, Windows) alias `.GIT`
89
+ if (top === '.git' || top === '.code-forge') throw new WorkerError('bad-path', `file may not be under ${segments[0]}/`);
90
+ return value;
91
+ }
92
+
93
+ /**
94
+ * Turn a `review-file` argument, given relative to `cwd`, into the repo-root-relative path (V4).
95
+ * The file's directory is realpath'd when it exists, so a symlinked directory cannot point the
96
+ * result outside the root.
97
+ * @param {string} raw @param {string} cwd @param {string} repoRoot - realpath'd.
98
+ * @returns {string}
99
+ * @throws {WorkerError} `bad-path`
100
+ */
101
+ export function normalizeRequestPath(raw, cwd, repoRoot) {
102
+ if (typeof raw !== 'string' || raw.length === 0) throw new WorkerError('bad-path', 'a path is required');
103
+ if (path.isAbsolute(raw)) throw new WorkerError('bad-path', 'give the path relative to the current directory, not absolute');
104
+ if (raw.split(/[/\\]/).includes('..')) throw new WorkerError('bad-path', 'the path may not contain a ".." segment');
105
+ const joined = path.resolve(realOrSelf(cwd), raw);
106
+ const real = path.join(realOrSelf(path.dirname(joined)), path.basename(joined));
107
+ const rel = path.relative(repoRoot, real);
108
+ if (!isInside(rel)) throw new WorkerError('bad-path', 'the path is outside the repository');
109
+ return assertInsideRoot(repoRoot, rel.split(path.sep).join('/'));
110
+ }
111
+
112
+ /** @param {string} rel @returns {boolean} whether a `path.relative` result stays inside. */
113
+ function isInside(rel) {
114
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
115
+ }
116
+
117
+ /**
118
+ * Re-check a repo-root-relative path on disk: the file itself may not be a symlink (it could
119
+ * point anywhere), and its directory, realpath'd, must still be inside the root. A missing file
120
+ * passes (a deletion is reviewable); its existing parent is still checked.
121
+ * @param {string} repoRoot - realpath'd. @param {string} file
122
+ * @returns {string} `file`
123
+ * @throws {WorkerError} `bad-path`
124
+ */
125
+ export function assertInsideRoot(repoRoot, file) {
126
+ const full = path.join(repoRoot, assertRowPath(file));
127
+ try {
128
+ if (lstatSync(full).isSymbolicLink()) throw new WorkerError('bad-path', 'the path is a symlink');
129
+ } catch (err) {
130
+ if (err instanceof WorkerError) throw err;
131
+ }
132
+ const rel = path.relative(realOrSelf(repoRoot), path.join(realOrSelf(path.dirname(full)), path.basename(full)));
133
+ if (!isInside(rel)) throw new WorkerError('bad-path', 'the path is outside the repository');
134
+ return file;
135
+ }
136
+
137
+ /** @param {string} p @returns {string} */
138
+ function realOrSelf(p) {
139
+ try {
140
+ return realpathSync(p);
141
+ } catch {
142
+ return path.resolve(p);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * sha256 of the file's bytes, or `DELETED_HASH` when it does not exist. Re-checks the path on
148
+ * disk first (`assertInsideRoot`: no symlinked file, no directory outside the root).
149
+ * @param {string} repoRoot @param {string} file - repo-root-relative.
150
+ * @returns {string}
151
+ */
152
+ export function contentHash(repoRoot, file) {
153
+ const bytes = readRegularFileNoFollow(repoRoot, file);
154
+ if (bytes === null) return DELETED_HASH;
155
+ return createHash('sha256').update(bytes).digest('hex');
156
+ }
157
+
158
+ /**
159
+ * Read a repo file's bytes without ever following a symlink, closing the lstat→read race (a
160
+ * regular file swapped for a symlink after `assertInsideRoot` checked it): the file is opened
161
+ * with `O_NOFOLLOW` (a symlink fails with ELOOP ⇒ `bad-path`) and `O_NONBLOCK` (a FIFO never
162
+ * blocks the open), the OPEN descriptor must be a regular file (`fstat`), and the bytes come from
163
+ * that descriptor. The realpath'd parent is checked inside the root before and after the open.
164
+ * @param {string} repoRoot - realpath'd. @param {string} file - repo-root-relative.
165
+ * @returns {Buffer | null} the bytes, or null when the file does not exist.
166
+ * @throws {WorkerError} `bad-path`
167
+ */
168
+ export function readRegularFileNoFollow(repoRoot, file) {
169
+ assertInsideRoot(repoRoot, file);
170
+ const full = path.join(repoRoot, file);
171
+ let fd;
172
+ try {
173
+ fd = openSync(full, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
174
+ } catch (err) {
175
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
176
+ if (code === 'ENOENT') return null;
177
+ if (code === 'ELOOP' || code === 'EMLINK') throw new WorkerError('bad-path', 'the path is a symlink');
178
+ throw err;
179
+ }
180
+ try {
181
+ if (!fstatSync(fd).isFile()) throw new WorkerError('bad-path', 'not a regular file');
182
+ assertInsideRoot(repoRoot, file);
183
+ return readFileSync(fd);
184
+ } finally {
185
+ closeSync(fd);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * @param {{run: string, block: string, file: string, content_hash: string}} t
191
+ * @returns {string} the ticket id.
192
+ */
193
+ export function ticketId({ run, block, file, content_hash }) {
194
+ return createHash('sha256').update([run, block, file, content_hash].join('\0')).digest('hex').slice(0, 24);
195
+ }