@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.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome rows — the ledger's ground truth for calibration (plan §6.3): the block gate (caller 2,
|
|
3
|
+
* a later block), `ledger outcome --pr <n> --ci ...` (caller 3, the CI template), and
|
|
4
|
+
* `--scan-git` (caller 4, C12) all funnel into `recordOutcome`.
|
|
5
|
+
*
|
|
6
|
+
* `scanGitOutcomes` is a PURE git→outcome-row mapper (no ledger I/O), testable without any JSONL
|
|
7
|
+
* file; `scanGitAndRecord` is the thin, IDEMPOTENT wrapper the CLI verb calls.
|
|
8
|
+
*
|
|
9
|
+
* Fix round 1 (2 MAJOR + 5 MINOR): the revert window is measured from each file's OWN review time
|
|
10
|
+
* (`reviewedAt`), not from when the scan happens to run — a global "now - 7 days" window means a
|
|
11
|
+
* revert 6 days after review is missed if the scan runs on day 10, and a revert 30 days after
|
|
12
|
+
* review counts if the scan runs the next day. `scanGitAndRecord` now skips (commit, file) pairs
|
|
13
|
+
* it already recorded, so running `--scan-git` twice does not double-count. `tokens_source`/
|
|
14
|
+
* `cost_source` on outcome rows: no extra code needed here — every row funnels through
|
|
15
|
+
* `write.mjs`'s `appendRow`, whose `normalizeRow` stamps both keys on 100% of rows unconditionally
|
|
16
|
+
* (see `write.test.mjs`); `test/ledger/outcome.test.mjs` asserts this directly for outcome rows too.
|
|
17
|
+
*
|
|
18
|
+
* Fix round 2: every `git` call here strips inherited `GIT_*` env vars (`GIT_DIR`, `GIT_WORK_TREE`,
|
|
19
|
+
* `GIT_INDEX_FILE`, ...) before running — the same class of bug the fixture builder had (fix
|
|
20
|
+
* round 2 MAJOR there), but this is production code: `forge ledger outcome --scan-git` running
|
|
21
|
+
* from inside any process that already has those set (a git hook, a CI step invoked BY git) would
|
|
22
|
+
* otherwise have `cwd` ignored for repository discovery and scan the WRONG repo entirely.
|
|
23
|
+
* (`exec` passes `env` straight to `spawn`, unmerged, so the stripped keys stay stripped.)
|
|
24
|
+
*
|
|
25
|
+
* Fix round 3: a revert-of-a-revert is decided by the `This reverts commit <sha>` CHAIN (odd depth
|
|
26
|
+
* ⇒ reverted, even ⇒ restored), with the subject as fallback — git ≥ 2.43 writes `Reapply "…"`
|
|
27
|
+
* for a revert of a revert, which the old `Revert "Revert` check missed. See `revertDepth`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { realpath } from 'node:fs/promises';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { exec } from '../util/exec.mjs';
|
|
33
|
+
import { appendRow, readAllRows } from './write.mjs';
|
|
34
|
+
|
|
35
|
+
const FIELD_SEP = '\x1f';
|
|
36
|
+
const RECORD_SEP = '\x1e';
|
|
37
|
+
|
|
38
|
+
/** `process.env`, minus every `GIT_*` key — see the fix-round-2 note above. */
|
|
39
|
+
function gitEnv() {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
42
|
+
if (!key.startsWith('GIT_')) out[key] = value;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** the body line `git revert` always writes: `This reverts commit <sha>.` (or `<sha> (<subject>, <date>).` with `--reference`). */
|
|
48
|
+
const REVERTS_COMMIT_BODY_RE = /This reverts commit ([0-9a-f]{7,40})\b/i;
|
|
49
|
+
/** Each leading `Revert "` in a subject is one revert level. */
|
|
50
|
+
const REVERT_PREFIX_RE = /^Revert ["“]/i;
|
|
51
|
+
/**
|
|
52
|
+
* Since git 2.43, reverting a commit whose subject is `Revert "X"` writes `Reapply "X"` instead
|
|
53
|
+
* of `Revert "Revert "X""` — so one leading `Reapply "` is TWO revert levels (git 2.50 verified:
|
|
54
|
+
* the chain is `X` → `Revert "X"` → `Reapply "X"` → `Revert "Reapply "X""` → `Reapply "Reapply "X""`).
|
|
55
|
+
*/
|
|
56
|
+
const REAPPLY_PREFIX_RE = /^Reapply ["“]/i;
|
|
57
|
+
/** Guard for a pathological or cyclic `This reverts commit` chain. */
|
|
58
|
+
const MAX_REVERT_CHAIN = 64;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How many revert levels the SUBJECT alone encodes: `Revert "` = 1, `Reapply "` = 2, nested
|
|
62
|
+
* prefixes add up. Odd ⇒ the change is undone; even and > 0 ⇒ it is restored; 0 ⇒ the subject
|
|
63
|
+
* says nothing (a hand-edited message).
|
|
64
|
+
* @param {string} subject @returns {number}
|
|
65
|
+
*/
|
|
66
|
+
function subjectRevertDepth(subject) {
|
|
67
|
+
let depth = 0;
|
|
68
|
+
let rest = subject;
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (REVERT_PREFIX_RE.test(rest)) {
|
|
71
|
+
depth += 1;
|
|
72
|
+
rest = rest.replace(REVERT_PREFIX_RE, '');
|
|
73
|
+
} else if (REAPPLY_PREFIX_RE.test(rest)) {
|
|
74
|
+
depth += 2;
|
|
75
|
+
rest = rest.replace(REAPPLY_PREFIX_RE, '');
|
|
76
|
+
} else {
|
|
77
|
+
return depth;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Exported for direct unit testing (fix round 1): the previous, unanchored `revert.*#\d+` pattern
|
|
84
|
+
* false-positived on subjects like "Prevent revert of cache (#12)" or "don't revert #3", and
|
|
85
|
+
* counted a revert-of-a-revert (which restores the original change) as a fresh "reverted" outcome.
|
|
86
|
+
*
|
|
87
|
+
* Fix round 3: this is the SUBJECT/BODY-only verdict — old-style `Revert "Revert "X""` AND
|
|
88
|
+
* git ≥ 2.43's `Reapply "X"` are both restores (false); a revert of a reapply is a revert again
|
|
89
|
+
* (true). A subject with no revert prefix falls back to the `This reverts commit` body marker.
|
|
90
|
+
* `scanGitOutcomes` goes further and follows the body's sha chain (see `revertDepth`), which also
|
|
91
|
+
* settles a hand-edited subject.
|
|
92
|
+
* @param {{subject: string, body: string}} commit @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function isRevertCommit({ subject, body }) {
|
|
95
|
+
const depth = subjectRevertDepth(subject);
|
|
96
|
+
if (depth > 0) return depth % 2 === 1;
|
|
97
|
+
return REVERTS_COMMIT_BODY_RE.test(body);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve one commit's subject/body by (possibly abbreviated) sha, or `null` if the repo does not
|
|
102
|
+
* have it (shallow clone, rewritten history).
|
|
103
|
+
* @param {string} sha @param {string} cwd
|
|
104
|
+
* @returns {Promise<{subject: string, body: string} | null>}
|
|
105
|
+
*/
|
|
106
|
+
async function lookupCommit(sha, cwd) {
|
|
107
|
+
const res = await exec(['git', 'log', '-1', `--pretty=format:%s${FIELD_SEP}%b`, `${sha}^{commit}`, '--'], { cwd, env: gitEnv() });
|
|
108
|
+
if (res.result !== 'ok') return null;
|
|
109
|
+
const [subject, body] = res.stdout.split(FIELD_SEP);
|
|
110
|
+
return { subject: subject ?? '', body: body ?? '' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Revert depth decided by the commit RELATION, not the wording: a commit whose body says
|
|
115
|
+
* `This reverts commit <sha>` is one level deeper than `<sha>` itself. Only where that chain
|
|
116
|
+
* cannot be followed (no body marker, or `<sha>` not in this repo) does the subject decide. So a
|
|
117
|
+
* hand-edited "Restore feature X" that reverts a revert is depth 2 (a restore), not a revert.
|
|
118
|
+
* @param {{subject: string, body: string}} commit
|
|
119
|
+
* @param {(sha: string) => Promise<{subject: string, body: string} | null>} lookup
|
|
120
|
+
* @returns {Promise<number>}
|
|
121
|
+
*/
|
|
122
|
+
async function revertDepth(commit, lookup) {
|
|
123
|
+
let levels = 0;
|
|
124
|
+
let current = commit;
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (let i = 0; i < MAX_REVERT_CHAIN; i += 1) {
|
|
127
|
+
const match = REVERTS_COMMIT_BODY_RE.exec(current.body);
|
|
128
|
+
if (!match) return levels + subjectRevertDepth(current.subject);
|
|
129
|
+
const sha = match[1].toLowerCase();
|
|
130
|
+
const parent = seen.has(sha) ? null : await lookup(sha);
|
|
131
|
+
seen.add(sha);
|
|
132
|
+
if (!parent) return levels + Math.max(1, subjectRevertDepth(current.subject));
|
|
133
|
+
levels += 1;
|
|
134
|
+
current = parent;
|
|
135
|
+
}
|
|
136
|
+
return levels;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} cwd @returns {Promise<string>} the repo root, so relative paths always match `reviewed[].file`. */
|
|
140
|
+
async function repoRoot(cwd) {
|
|
141
|
+
const res = await exec(['git', 'rev-parse', '--show-toplevel'], { cwd, env: gitEnv() });
|
|
142
|
+
if (res.result !== 'ok') throw new Error(`git rev-parse --show-toplevel failed: ${res.stderr || res.error}`);
|
|
143
|
+
return res.stdout.trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} cwd @param {{since?: Date, until?: Date}} [range] - a coarse pre-filter so a
|
|
148
|
+
* large repo's `git log` output doesn't have to enumerate its whole history through `exec`'s
|
|
149
|
+
* buffer; per-file window checks still happen in JS afterward.
|
|
150
|
+
* @returns {Promise<Array<{hash: string, committedAt: string, subject: string, body: string}>>}
|
|
151
|
+
*/
|
|
152
|
+
async function listCommits(cwd, range = {}) {
|
|
153
|
+
const args = ['git', 'log', `--pretty=format:%H${FIELD_SEP}%cI${FIELD_SEP}%s${FIELD_SEP}%b${RECORD_SEP}`];
|
|
154
|
+
if (range.since) args.push(`--since=${range.since.toISOString()}`);
|
|
155
|
+
if (range.until) args.push(`--until=${range.until.toISOString()}`);
|
|
156
|
+
const res = await exec(args, { cwd, env: gitEnv() });
|
|
157
|
+
if (res.result !== 'ok') throw new Error(`git log failed: ${res.stderr || res.error}`);
|
|
158
|
+
return res.stdout
|
|
159
|
+
.split(RECORD_SEP)
|
|
160
|
+
.map((chunk) => chunk.replace(/^\n/, ''))
|
|
161
|
+
.filter((chunk) => chunk.length > 0)
|
|
162
|
+
.map((chunk) => {
|
|
163
|
+
const [hash, committedAt, subject, body] = chunk.split(FIELD_SEP);
|
|
164
|
+
return { hash, committedAt, subject, body: body ?? '' };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `-c core.quotePath=false` — without it, git C-style-escapes a path containing non-ASCII or
|
|
170
|
+
* special characters (e.g. `src/é.mjs` → `"src/\303\251.mjs"`), which would never match a
|
|
171
|
+
* `reviewed[].file` entry and silently drop that file's revert.
|
|
172
|
+
* @param {string} hash @param {string} cwd @returns {Promise<string[]>}
|
|
173
|
+
*/
|
|
174
|
+
async function changedFiles(hash, cwd) {
|
|
175
|
+
const res = await exec(['git', '-c', 'core.quotePath=false', 'diff-tree', '--no-commit-id', '--name-only', '-r', hash], {
|
|
176
|
+
cwd,
|
|
177
|
+
env: gitEnv(),
|
|
178
|
+
});
|
|
179
|
+
if (res.result !== 'ok') throw new Error(`git diff-tree failed for ${hash}: ${res.stderr || res.error}`);
|
|
180
|
+
return res.stdout.split('\n').filter((line) => line.length > 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Group `reviewed` entries by file, KEEPING EVERY REVIEW (fix round 2 — a version that kept only
|
|
185
|
+
* the latest review per file would miss a revert that lands between an EARLIER review and a LATER
|
|
186
|
+
* one: file X reviewed in block A, reverted 3 days later, then reviewed again in block B before
|
|
187
|
+
* any scan runs — the revert now sits before B's `reviewedAt`, and "only the latest review" would
|
|
188
|
+
* silently drop it). Each file's list is sorted ascending by `reviewedAt` so the scan loop can find
|
|
189
|
+
* the latest review STRICTLY BEFORE a given revert commit's time.
|
|
190
|
+
*
|
|
191
|
+
* An entry whose `reviewedAt` does not parse to a finite time is skipped outright — a NaN in the
|
|
192
|
+
* mix would otherwise poison every `Math.min`/`Math.max` over the group into NaN, and
|
|
193
|
+
* `new Date(NaN).toISOString()` throws, crashing the scan over ONE bad ledger row instead of just
|
|
194
|
+
* ignoring it.
|
|
195
|
+
* @param {Array<{file: string, reviewedAt: string|Date, block?: string, run?: string}>} reviewed
|
|
196
|
+
* @returns {Map<string, Array<{reviewedAt: Date, block?: string, run?: string}>>}
|
|
197
|
+
*/
|
|
198
|
+
function reviewsByFile(reviewed) {
|
|
199
|
+
/** @type {Map<string, Array<{reviewedAt: Date, block?: string, run?: string}>>} */
|
|
200
|
+
const byFile = new Map();
|
|
201
|
+
for (const entry of reviewed) {
|
|
202
|
+
const reviewedAt = new Date(entry.reviewedAt);
|
|
203
|
+
if (!Number.isFinite(reviewedAt.getTime())) continue;
|
|
204
|
+
const list = byFile.get(entry.file) ?? [];
|
|
205
|
+
list.push({ reviewedAt, block: entry.block, run: entry.run });
|
|
206
|
+
byFile.set(entry.file, list);
|
|
207
|
+
}
|
|
208
|
+
for (const list of byFile.values()) list.sort((a, b) => a.reviewedAt.getTime() - b.reviewedAt.getTime());
|
|
209
|
+
return byFile;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The latest review in `list` (ascending-sorted by `reviewedAt`) strictly BEFORE `commitTimeMs` —
|
|
214
|
+
* the review a revert at that time is judged against — or `undefined` if none qualifies.
|
|
215
|
+
* @param {Array<{reviewedAt: Date}>} list @param {number} commitTimeMs
|
|
216
|
+
*/
|
|
217
|
+
function latestReviewBefore(list, commitTimeMs) {
|
|
218
|
+
let found;
|
|
219
|
+
for (const entry of list) {
|
|
220
|
+
if (entry.reviewedAt.getTime() < commitTimeMs) found = entry;
|
|
221
|
+
else break; // sorted ascending — nothing later in the list can qualify either
|
|
222
|
+
}
|
|
223
|
+
return found;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `reviewed[].file` as given may be relative to a subdirectory `cwd`, or absolute; git's own
|
|
228
|
+
* output (`diff-tree --name-only`) is always relative to the REPO ROOT. Without normalizing both
|
|
229
|
+
* sides to the same base, a ledger that stores paths relative to `cwd` would never match anything
|
|
230
|
+
* and silently return 0 rows.
|
|
231
|
+
*
|
|
232
|
+
* `root` (from `git rev-parse --show-toplevel`) is ALREADY the real, symlink-resolved path — on
|
|
233
|
+
* macOS, `os.tmpdir()`/a raw `cwd` is typically `/var/folders/...`, a symlink to
|
|
234
|
+
* `/private/var/folders/...`, which is what git actually reports. Comparing an unresolved `cwd`
|
|
235
|
+
* against the resolved `root` with plain string-based `path.relative` produces a bogus `../../...`
|
|
236
|
+
* chain instead of collapsing to the real relative path, so `cwd` is realpath'd here too.
|
|
237
|
+
* @param {string} root @param {string} cwd @param {string} file
|
|
238
|
+
* @returns {Promise<string>}
|
|
239
|
+
*/
|
|
240
|
+
async function toRepoRootRelative(root, cwd, file) {
|
|
241
|
+
if (path.isAbsolute(file)) return path.relative(root, await realpathOfNearestAncestor(file));
|
|
242
|
+
const realCwd = await realpath(cwd).catch(() => cwd);
|
|
243
|
+
return path.relative(root, path.resolve(realCwd, file));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `realpath` of `p`, or — when `p` no longer exists (the revert deleted it) — of its nearest
|
|
248
|
+
* EXISTING ancestor with the missing tail joined back on (fix round 3). Falling back to the raw
|
|
249
|
+
* path instead left macOS's `/var` ↔ `/private/var` symlink unresolved, so a deleted file's revert
|
|
250
|
+
* produced a bogus `../..` path and was silently missed.
|
|
251
|
+
* @param {string} p @returns {Promise<string>}
|
|
252
|
+
*/
|
|
253
|
+
async function realpathOfNearestAncestor(p) {
|
|
254
|
+
const tail = [];
|
|
255
|
+
let current = p;
|
|
256
|
+
for (;;) {
|
|
257
|
+
try {
|
|
258
|
+
return path.join(await realpath(current), ...tail);
|
|
259
|
+
} catch {
|
|
260
|
+
const parent = path.dirname(current);
|
|
261
|
+
if (parent === current) return p;
|
|
262
|
+
tail.unshift(path.basename(current));
|
|
263
|
+
current = parent;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Walk `cwd`'s git log for revert-shaped commits that land within `days` days AFTER a reviewed
|
|
270
|
+
* file's OWN `reviewedAt` (plan §6.3 caller 4, C12) — never a window relative to when the scan
|
|
271
|
+
* happens to run, and never only the file's single latest review (see `reviewsByFile`).
|
|
272
|
+
* @param {{cwd: string, reviewed: Array<{file: string, reviewedAt: string|Date, block?: string, run?: string}>, days?: number, now?: Date}} args
|
|
273
|
+
* @returns {Promise<any[]>} outcome rows found — not yet appended to the ledger.
|
|
274
|
+
*/
|
|
275
|
+
export async function scanGitOutcomes({ cwd, reviewed, days = 7, now = new Date() }) {
|
|
276
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
277
|
+
throw new RangeError(`scanGitOutcomes: days must be a finite number > 0, got ${days}`);
|
|
278
|
+
}
|
|
279
|
+
const root = await repoRoot(cwd);
|
|
280
|
+
const normalized = await Promise.all(reviewed.map(async (entry) => ({ ...entry, file: await toRepoRootRelative(root, cwd, entry.file) })));
|
|
281
|
+
const byFile = reviewsByFile(normalized);
|
|
282
|
+
if (byFile.size === 0) return [];
|
|
283
|
+
|
|
284
|
+
// A loop, not Math.min(...all) — spreading one argument per review.done row overflows the call
|
|
285
|
+
// stack on a large ledger.
|
|
286
|
+
let minMs = Infinity;
|
|
287
|
+
let maxMs = -Infinity;
|
|
288
|
+
for (const list of byFile.values()) {
|
|
289
|
+
for (const r of list) {
|
|
290
|
+
const t = r.reviewedAt.getTime();
|
|
291
|
+
if (t < minMs) minMs = t;
|
|
292
|
+
if (t > maxMs) maxMs = t;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const windowMs = days * 24 * 60 * 60 * 1000;
|
|
296
|
+
const since = new Date(minMs);
|
|
297
|
+
const until = new Date(Math.min(maxMs + windowMs, now.getTime()));
|
|
298
|
+
|
|
299
|
+
const commits = await listCommits(root, { since, until });
|
|
300
|
+
/** @type {Map<string, Promise<{subject: string, body: string} | null>>} */
|
|
301
|
+
const lookupCache = new Map();
|
|
302
|
+
for (const c of commits) lookupCache.set(c.hash, Promise.resolve(c));
|
|
303
|
+
const lookup = (sha) => {
|
|
304
|
+
const known = sha.length === 40 ? lookupCache.get(sha) : undefined;
|
|
305
|
+
if (known) return known;
|
|
306
|
+
if (!lookupCache.has(sha)) lookupCache.set(sha, lookupCommit(sha, root));
|
|
307
|
+
return /** @type {Promise<{subject: string, body: string} | null>} */ (lookupCache.get(sha));
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const rows = [];
|
|
311
|
+
for (const commit of commits) {
|
|
312
|
+
// Cheap pre-filter: a commit with neither a revert/reapply subject nor the body marker is not
|
|
313
|
+
// part of any revert chain.
|
|
314
|
+
if (subjectRevertDepth(commit.subject) === 0 && !REVERTS_COMMIT_BODY_RE.test(commit.body)) continue;
|
|
315
|
+
if ((await revertDepth(commit, lookup)) % 2 !== 1) continue; // even ⇒ restores the change
|
|
316
|
+
const committedAtMs = new Date(commit.committedAt).getTime();
|
|
317
|
+
if (committedAtMs > now.getTime()) continue; // future-dated / skewed committer clock
|
|
318
|
+
for (const file of await changedFiles(commit.hash, root)) {
|
|
319
|
+
const list = byFile.get(file);
|
|
320
|
+
if (!list) continue;
|
|
321
|
+
const review = latestReviewBefore(list, committedAtMs);
|
|
322
|
+
if (!review) continue; // no review of this file precedes the revert at all
|
|
323
|
+
if (committedAtMs > review.reviewedAt.getTime() + windowMs) continue; // outside THAT review's window
|
|
324
|
+
rows.push({
|
|
325
|
+
event: 'outcome',
|
|
326
|
+
outcome: 'reverted',
|
|
327
|
+
outcome_source: 'git',
|
|
328
|
+
file,
|
|
329
|
+
commit: commit.hash,
|
|
330
|
+
subject: commit.subject,
|
|
331
|
+
committed_at: commit.committedAt,
|
|
332
|
+
...(review.block ? { block: review.block } : {}),
|
|
333
|
+
...(review.run ? { run: review.run } : {}),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return rows;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** @param {object} outcomeData @param {{slug: string}} opts */
|
|
341
|
+
export const recordOutcome = (outcomeData, opts) => appendRow({ event: 'outcome', ...outcomeData }, opts);
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Scan + persist in one call — what `forge ledger outcome --scan-git` runs. IDEMPOTENT: skips any
|
|
345
|
+
* (commit, file) pair that already has a `git`-sourced outcome row for this slug, so running it
|
|
346
|
+
* twice (a re-triggered CI job, a human re-running it) does not double-count the same revert.
|
|
347
|
+
* @param {{cwd: string, reviewed: Array<{file: string, reviewedAt: string|Date, block?: string, run?: string}>, days?: number, now?: Date, slug: string}} args
|
|
348
|
+
* @returns {Promise<any[]>} the outcome rows actually appended (excludes already-recorded ones).
|
|
349
|
+
*/
|
|
350
|
+
export async function scanGitAndRecord({ cwd, reviewed, days, now, slug }) {
|
|
351
|
+
const found = await scanGitOutcomes({ cwd, reviewed, days, now });
|
|
352
|
+
const existing = await readAllRows(slug);
|
|
353
|
+
const already = new Set(
|
|
354
|
+
existing.filter((r) => r.event === 'outcome' && r.outcome_source === 'git').map((r) => `${r.commit}::${r.file}`),
|
|
355
|
+
);
|
|
356
|
+
const written = [];
|
|
357
|
+
for (const row of found) {
|
|
358
|
+
const key = `${row.commit}::${row.file}`;
|
|
359
|
+
if (already.has(key)) continue;
|
|
360
|
+
written.push(await recordOutcome(row, { slug }));
|
|
361
|
+
already.add(key);
|
|
362
|
+
}
|
|
363
|
+
return written;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Build the `reviewed` argument for `scanGitOutcomes`/`scanGitAndRecord` from a ledger's own rows:
|
|
368
|
+
* EVERY `review.done` row becomes its own entry (fix round 2 — collapsing to one-per-file here
|
|
369
|
+
* would silently undo `scanGitOutcomes`'s "keep every review" fix: a file reviewed twice, with a
|
|
370
|
+
* revert landing between the two reviews, needs BOTH review timestamps present so the revert can
|
|
371
|
+
* be matched to the one that actually preceded it).
|
|
372
|
+
* @param {any[]} rows
|
|
373
|
+
* @returns {Array<{file: string, reviewedAt: string, block?: string}>}
|
|
374
|
+
*/
|
|
375
|
+
export function reviewedEntriesFromRows(rows) {
|
|
376
|
+
return rows
|
|
377
|
+
.filter((r) => r.event === 'review.done' && r.file && r.ts)
|
|
378
|
+
.map((r) => ({ file: r.file, reviewedAt: r.ts, ...(r.block ? { block: r.block } : {}) }));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const CI_TO_OUTCOME = Object.freeze({ red: 'wrong', green: 'correct', reverted: 'reverted' });
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* `forge ledger outcome --pr <n> --ci red|green|reverted` (plan §6.3 caller 3, the CI template).
|
|
385
|
+
* @param {{pr: number, ci: 'red'|'green'|'reverted', slug: string}} args
|
|
386
|
+
*/
|
|
387
|
+
export function recordCiOutcome({ pr, ci, slug }) {
|
|
388
|
+
// Object.hasOwn (not `CI_TO_OUTCOME[ci]` truthiness) so `--ci toString`/`--ci constructor` can't
|
|
389
|
+
// walk the prototype chain to a function and slip past the RangeError guard below.
|
|
390
|
+
if (!Object.hasOwn(CI_TO_OUTCOME, ci)) {
|
|
391
|
+
throw new RangeError(`recordCiOutcome: unknown --ci value "${ci}"`);
|
|
392
|
+
}
|
|
393
|
+
if (!Number.isInteger(pr) || pr <= 0) {
|
|
394
|
+
throw new RangeError(`recordCiOutcome: pr must be a positive integer, got ${pr}`);
|
|
395
|
+
}
|
|
396
|
+
return recordOutcome({ outcome: CI_TO_OUTCOME[ci], outcome_source: 'ci', pr }, { slug });
|
|
397
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the ledger lives on disk: `~/.code-forge/ledger/<slug>.jsonl` (plan §1.2, §6).
|
|
3
|
+
* `os.homedir()` re-reads `process.env.HOME` on every call (POSIX), so a test that points `HOME`
|
|
4
|
+
* at a temp dir before calling in gets a fully isolated ledger — nothing here ever touches a
|
|
5
|
+
* real `~/.code-forge/`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** A project slug: lowercase, digits, hyphens — same shape `project.slug` takes in the schema. */
|
|
12
|
+
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
13
|
+
|
|
14
|
+
/** @returns {string} */
|
|
15
|
+
export function ledgerDir() {
|
|
16
|
+
return path.join(homedir(), '.code-forge', 'ledger');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} slug
|
|
21
|
+
* @returns {string}
|
|
22
|
+
* @throws {TypeError} on an empty/invalid slug — a bad slug must never resolve to a path outside
|
|
23
|
+
* `ledgerDir()` (e.g. via `..` or `/`), so this rejects anything that isn't a plain slug token.
|
|
24
|
+
*/
|
|
25
|
+
export function ledgerPath(slug) {
|
|
26
|
+
if (typeof slug !== 'string' || !SLUG_PATTERN.test(slug)) {
|
|
27
|
+
throw new TypeError(`ledgerPath: slug must match ${SLUG_PATTERN} (got ${JSON.stringify(slug)})`);
|
|
28
|
+
}
|
|
29
|
+
return path.join(ledgerDir(), `${slug}.jsonl`);
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.1's static cost table (plan §4.10, R4): forecast tokens per review depth, and a $/1K-token
|
|
3
|
+
* price table used ONLY to estimate `cost_usd`. Fixture figures, never a live price feed — no
|
|
4
|
+
* network call ever backs a number here; the 0.2 ledger-averaged forecast replaces this table once
|
|
5
|
+
* ≥ 10 real samples exist per depth (§4.10).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Estimated total (in+out) review tokens per depth — §4.10's static table, verbatim. */
|
|
9
|
+
export const REVIEW_DEPTH_TOKENS = Object.freeze({
|
|
10
|
+
quick: 7000,
|
|
11
|
+
full: 13000,
|
|
12
|
+
'a-b-judge': 34000,
|
|
13
|
+
consensus: 30000,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** $ per 1000 tokens (blended in+out) by provider and level — placeholder fixture prices. */
|
|
17
|
+
export const PRICE_PER_1K_TOKENS_USD = Object.freeze({
|
|
18
|
+
anthropic: Object.freeze({ L0: 0.001, L1: 0.003, L2: 0.015, L3: 0.03 }),
|
|
19
|
+
openai: Object.freeze({ L0: 0.001, L1: 0.0025, L2: 0.012, L3: 0.025 }),
|
|
20
|
+
xai: Object.freeze({ L0: 0.001, L1: 0.002, L2: 0.01, L3: 0.02 }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{provider: string, level: string, tokensIn?: number, tokensOut?: number}} args
|
|
25
|
+
* @returns {number} an ESTIMATED cost in USD, rounded to 4 decimal places.
|
|
26
|
+
*/
|
|
27
|
+
export function estimateCostUsd({ provider, level, tokensIn = 0, tokensOut = 0 }) {
|
|
28
|
+
const table = PRICE_PER_1K_TOKENS_USD[provider];
|
|
29
|
+
if (!table) throw new RangeError(`estimateCostUsd: unknown provider "${provider}"`);
|
|
30
|
+
const perK = table[level];
|
|
31
|
+
if (typeof perK !== 'number') throw new RangeError(`estimateCostUsd: unknown level "${level}" for "${provider}"`);
|
|
32
|
+
// A ledger row's counts come from a JSON.parse'd file, not a type system — a string ("100"+"50"
|
|
33
|
+
// string-concatenates instead of adding), NaN, or a negative count must fail loudly here rather
|
|
34
|
+
// than quietly corrupting every downstream cost total.
|
|
35
|
+
if (!Number.isFinite(tokensIn) || tokensIn < 0) throw new RangeError(`estimateCostUsd: tokensIn must be a finite number >= 0, got ${JSON.stringify(tokensIn)}`);
|
|
36
|
+
if (!Number.isFinite(tokensOut) || tokensOut < 0) throw new RangeError(`estimateCostUsd: tokensOut must be a finite number >= 0, got ${JSON.stringify(tokensOut)}`);
|
|
37
|
+
return Math.round(((tokensIn + tokensOut) / 1000) * perK * 10000) / 10000;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} depth - one of `quick`, `full`, `a-b-judge`, `consensus`; anything else throws
|
|
42
|
+
* (a `string`, not the literal union, since a CLI/ledger caller has no compile-time guarantee).
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
export function forecastTokensForDepth(depth) {
|
|
46
|
+
const tokens = REVIEW_DEPTH_TOKENS[depth];
|
|
47
|
+
if (typeof tokens !== 'number') throw new RangeError(`forecastTokensForDepth: unknown depth "${depth}"`);
|
|
48
|
+
return tokens;
|
|
49
|
+
}
|