@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,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof tiers (plan §7.1, D14): two tiers, decided per file at `review-file` time and enforced
|
|
3
|
+
* by the block gate as the UNION of its files' tiers.
|
|
4
|
+
*
|
|
5
|
+
* high ⇐ risk ≥ 2, or the path matches `proof.tiers.high.paths`, or `security_sensitive`
|
|
6
|
+
* light ⇐ everything else (the review-depth band `1 ≤ risk < 2` does NOT raise the proof tier)
|
|
7
|
+
*
|
|
8
|
+
* The light tier's proof is red→green (B10b). The high tier's extra proof — tool-made mutants on
|
|
9
|
+
* changed lines — is held on Q16 (block B10c), so `proofRequirements('high')` reports it as
|
|
10
|
+
* `held` and the gate enforces red→green only until Ricardo answers. Pure: no I/O.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
14
|
+
|
|
15
|
+
/** @typedef {'light' | 'high'} Tier */
|
|
16
|
+
/** @typedef {'risk' | 'path' | 'security' | 'default'} TierReason */
|
|
17
|
+
|
|
18
|
+
export const TIERS = Object.freeze(/** @type {const} */ (['light', 'high']));
|
|
19
|
+
|
|
20
|
+
/** `risk ≥ HIGH_RISK` ⇒ high tier (§7.1). */
|
|
21
|
+
export const HIGH_RISK = 2;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{file: string, risk: number, securitySensitive?: boolean, highPaths?: ReadonlyArray<string>}} input
|
|
25
|
+
* `risk` is the S1 `risk` answer (0–3); `highPaths` is `proof.tiers.high.paths` (exact paths or
|
|
26
|
+
* globs in the registry's syntax: `*`, `?`, `**`, `{a,b}`).
|
|
27
|
+
* @returns {{tier: Tier, reason: TierReason}} the FIRST reason that applies, in the order
|
|
28
|
+
* security, risk, path — every one of them yields `high`, so the order only picks the label.
|
|
29
|
+
* @throws {TypeError} on a non-string file, a risk outside 0–3, or a non-array `highPaths`.
|
|
30
|
+
*/
|
|
31
|
+
export function tierFor({ file, risk, securitySensitive = false, highPaths = [] }) {
|
|
32
|
+
if (typeof file !== 'string' || file.length === 0) throw new TypeError('tierFor: file must be a non-empty repo-relative path');
|
|
33
|
+
if (typeof risk !== 'number' || !Number.isFinite(risk) || risk < 0 || risk > 3) {
|
|
34
|
+
throw new TypeError('tierFor: risk must be a number from 0 to 3');
|
|
35
|
+
}
|
|
36
|
+
if (!Array.isArray(highPaths)) throw new TypeError('tierFor: proof.tiers.high.paths must be an array');
|
|
37
|
+
if (securitySensitive === true) return { tier: 'high', reason: 'security' };
|
|
38
|
+
if (risk >= HIGH_RISK) return { tier: 'high', reason: 'risk' };
|
|
39
|
+
if (highPaths.length > 0 && ownsFile(highPaths, file)) return { tier: 'high', reason: 'path' };
|
|
40
|
+
return { tier: 'light', reason: 'default' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The block's tier: the union of its files' tiers (`high` when any file is `high`).
|
|
45
|
+
* @param {ReadonlyArray<{tier: Tier}>} fileTiers
|
|
46
|
+
* @returns {Tier}
|
|
47
|
+
*/
|
|
48
|
+
export function blockTier(fileTiers) {
|
|
49
|
+
if (!Array.isArray(fileTiers)) throw new TypeError('blockTier: fileTiers must be an array');
|
|
50
|
+
return fileTiers.some((t) => t?.tier === 'high') ? 'high' : 'light';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What the gate requires for a tier. `mutants: 'held'` = the high tier's mutation proof is
|
|
55
|
+
* specified but not enforced until Q16 is answered (B10c).
|
|
56
|
+
* @param {Tier} tier
|
|
57
|
+
* @returns {{red_green: true, mutants: 'none' | 'held'}}
|
|
58
|
+
*/
|
|
59
|
+
export function proofRequirements(tier) {
|
|
60
|
+
if (!TIERS.includes(tier)) throw new TypeError(`proofRequirements: tier must be one of ${TIERS.join(', ')}`);
|
|
61
|
+
return { red_green: true, mutants: tier === 'high' ? 'held' : 'none' };
|
|
62
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review budget per block (plan §4.10, R4, O19; block B12b) — the middle point.
|
|
3
|
+
*
|
|
4
|
+
* Budget = `review.block_budget_tokens` (150 000) + whatever the orchestrator raised for the run
|
|
5
|
+
* (`raised`). The forecast is the 0.1 static table (all `estimated`, [A31] for the recheck):
|
|
6
|
+
* quick 7k · full 13k · A+B+judge 34k (26k for the two lenses + 8k judge) · consensus 30k ·
|
|
7
|
+
* recheck 5k.
|
|
8
|
+
* Unconstrained depth per file: tier `light` ⇒ quick, `standard` ⇒ full, `high` ⇒ A+B+judge
|
|
9
|
+
* (`consensus` for every file under `review.multimodel: true`).
|
|
10
|
+
*
|
|
11
|
+
* Degrade order when the forecast exceeds the budget — one step at a time, cheapest first,
|
|
12
|
+
* re-forecasting after each, stopping at the first step that fits:
|
|
13
|
+
* 1. batch small files of the same tier into one packet (≤ `full_in`; one row per file;
|
|
14
|
+
* `review.batch_small_files: false` skips it) — a file is small when its estimated packet
|
|
15
|
+
* is ≤ `full_in / 3`;
|
|
16
|
+
* 2. high tier: the judge runs once per BLOCK instead of per file (lens A + B still per file);
|
|
17
|
+
* 3. standard band: `full` ⇒ `quick`;
|
|
18
|
+
* 4. high tier: `A + B + judge` ⇒ one `full` L2 + judge — the FLOOR for the high tier
|
|
19
|
+
* (consensus floor = the full consensus: steps 2–4 never touch it);
|
|
20
|
+
* (5. light: `quick` stays `quick` — a no-op, never counted.)
|
|
21
|
+
* Still over after step 4 ⇒ `over_budget`: the orchestrator raises the budget once per run or
|
|
22
|
+
* splits the block. Never silently, never below the floors. RECHECKS ARE NEVER DEGRADED: a
|
|
23
|
+
* round ≥ 2 packet is already the minimum (§4.11); they are never batched either.
|
|
24
|
+
*
|
|
25
|
+
* Rows (R4 dataset): per file `review.plan {file, tier, depth_unconstrained, depth_chosen,
|
|
26
|
+
* degrade_step, forecast_tokens, context_mode}` and per block `review.budget {budget,
|
|
27
|
+
* forecast_unconstrained, forecast_chosen, actual, raised, over_budget}`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { budgetFor } from './packet.mjs';
|
|
31
|
+
|
|
32
|
+
export const SESSION_COST = Object.freeze({ quick: 7000, full: 13000, dual: 34000, consensus: 30000, recheck: 5000 });
|
|
33
|
+
|
|
34
|
+
/** The judge's share of `dual` (A + B = 2 × 13k; the judge reads two reports). */
|
|
35
|
+
export const JUDGE_COST = SESSION_COST.dual - 2 * SESSION_COST.full;
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_BLOCK_BUDGET = 150000;
|
|
38
|
+
|
|
39
|
+
/** Per-file cost of each depth when the judge is billed separately (per block). */
|
|
40
|
+
const PER_FILE = Object.freeze({
|
|
41
|
+
quick: SESSION_COST.quick,
|
|
42
|
+
full: SESSION_COST.full,
|
|
43
|
+
dual: SESSION_COST.dual,
|
|
44
|
+
'dual-block-judge': 2 * SESSION_COST.full,
|
|
45
|
+
'full+judge': SESSION_COST.full + JUDGE_COST,
|
|
46
|
+
'full+block-judge': SESSION_COST.full,
|
|
47
|
+
consensus: SESSION_COST.consensus,
|
|
48
|
+
recheck: SESSION_COST.recheck,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tier per file (§7.1): `high` when risk ≥ 2, a high path, or security-sensitive; `standard` for
|
|
53
|
+
* 1 ≤ risk < 2; else `light`.
|
|
54
|
+
* @param {{risk: number, highPath?: boolean, securitySensitive?: boolean}} opts
|
|
55
|
+
* @returns {'light' | 'standard' | 'high'}
|
|
56
|
+
*/
|
|
57
|
+
export function tierOf({ risk, highPath = false, securitySensitive = false }) {
|
|
58
|
+
if (highPath || securitySensitive || risk >= 2) return 'high';
|
|
59
|
+
return risk >= 1 ? 'standard' : 'light';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} BudgetFile
|
|
64
|
+
* @property {string} file
|
|
65
|
+
* @property {'light' | 'standard' | 'high'} tier
|
|
66
|
+
* @property {number} [tokens] - the estimated packet size (for batching); absent ⇒ not small.
|
|
67
|
+
* @property {boolean} [recheck] - a round ≥ 2 packet: forecast at `recheck`, never degraded.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} PlanEntry
|
|
72
|
+
* @property {string} file @property {string} tier
|
|
73
|
+
* @property {string} depth_unconstrained @property {string} depth_chosen
|
|
74
|
+
* @property {number} degrade_step @property {number} forecast_tokens
|
|
75
|
+
* @property {string | null} context_mode @property {number | null} batch
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {object} BudgetPlan
|
|
80
|
+
* @property {number} budget @property {number} raised
|
|
81
|
+
* @property {number} forecast_unconstrained @property {number} forecast_chosen
|
|
82
|
+
* @property {number} degrade_step - the last step applied (0 = none).
|
|
83
|
+
* @property {boolean} over_budget
|
|
84
|
+
* @property {boolean} judge_per_block
|
|
85
|
+
* @property {PlanEntry[]} plan
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/** @param {'light' | 'standard' | 'high'} tier @param {boolean} consensus */
|
|
89
|
+
const unconstrainedDepth = (tier, consensus) => (consensus ? 'consensus' : tier === 'high' ? 'dual' : tier === 'standard' ? 'full' : 'quick');
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Forecast the plan: per-file costs, batches billed once per batch, the block judge once.
|
|
93
|
+
* @param {PlanEntry[]} entries @param {boolean} judgePerBlock @returns {number}
|
|
94
|
+
*/
|
|
95
|
+
function forecast(entries, judgePerBlock) {
|
|
96
|
+
let total = 0;
|
|
97
|
+
/** @type {Map<number, number>} */
|
|
98
|
+
const batches = new Map();
|
|
99
|
+
for (const e of entries) {
|
|
100
|
+
const cost = PER_FILE[/** @type {keyof typeof PER_FILE} */ (e.depth_chosen)];
|
|
101
|
+
if (e.batch === null) {
|
|
102
|
+
e.forecast_tokens = cost;
|
|
103
|
+
total += cost;
|
|
104
|
+
} else {
|
|
105
|
+
batches.set(e.batch, Math.max(batches.get(e.batch) ?? 0, cost));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const [id, cost] of batches) {
|
|
109
|
+
const members = entries.filter((e) => e.batch === id);
|
|
110
|
+
for (const e of members) e.forecast_tokens = Math.ceil(cost / members.length);
|
|
111
|
+
total += cost;
|
|
112
|
+
}
|
|
113
|
+
const blockJudge = judgePerBlock && entries.some((e) => e.depth_chosen === 'dual-block-judge' || e.depth_chosen === 'full+block-judge');
|
|
114
|
+
return total + (blockJudge ? JUDGE_COST : 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Plan the block's review depth under its budget.
|
|
119
|
+
* @param {{files: BudgetFile[], cfg?: Record<string, any>, raised?: number}} opts
|
|
120
|
+
* @returns {BudgetPlan}
|
|
121
|
+
*/
|
|
122
|
+
export function planBudget({ files, cfg = {}, raised = 0 }) {
|
|
123
|
+
if (!Array.isArray(files)) throw new TypeError('planBudget: files must be an array');
|
|
124
|
+
const base = Number.isInteger(cfg?.review?.block_budget_tokens) && cfg.review.block_budget_tokens > 0 ? cfg.review.block_budget_tokens : DEFAULT_BLOCK_BUDGET;
|
|
125
|
+
if (!Number.isInteger(raised) || raised < 0) throw new TypeError('planBudget: raised must be an integer >= 0');
|
|
126
|
+
const budget = base + raised;
|
|
127
|
+
const consensus = cfg?.review?.multimodel === true;
|
|
128
|
+
const fullIn = budgetFor(cfg, 'full_in');
|
|
129
|
+
|
|
130
|
+
/** @type {PlanEntry[]} */
|
|
131
|
+
const plan = files.map((f) => {
|
|
132
|
+
const depth = f.recheck === true ? 'recheck' : unconstrainedDepth(f.tier, consensus);
|
|
133
|
+
return { file: f.file, tier: f.tier, depth_unconstrained: depth, depth_chosen: depth, degrade_step: 0, forecast_tokens: 0, context_mode: f.recheck === true ? 'recheck' : null, batch: null };
|
|
134
|
+
});
|
|
135
|
+
let judgePerBlock = false;
|
|
136
|
+
const unconstrained = forecast(plan, false);
|
|
137
|
+
let chosen = unconstrained;
|
|
138
|
+
let step = 0;
|
|
139
|
+
|
|
140
|
+
/** @type {Array<() => void>} */
|
|
141
|
+
const steps = [
|
|
142
|
+
// 1. batch small files of the same tier
|
|
143
|
+
() => {
|
|
144
|
+
if (cfg?.review?.batch_small_files === false) return;
|
|
145
|
+
let next = 0;
|
|
146
|
+
for (const tier of ['light', 'standard', 'high']) {
|
|
147
|
+
let load = 0;
|
|
148
|
+
let current = -1;
|
|
149
|
+
files.forEach((f, i) => {
|
|
150
|
+
const e = plan[i];
|
|
151
|
+
if (f.recheck === true || f.tier !== tier || !(typeof f.tokens === 'number' && f.tokens <= fullIn / 3)) return;
|
|
152
|
+
if (current < 0 || load + f.tokens > fullIn) {
|
|
153
|
+
current = next++;
|
|
154
|
+
load = 0;
|
|
155
|
+
}
|
|
156
|
+
load += f.tokens;
|
|
157
|
+
e.batch = current;
|
|
158
|
+
e.degrade_step = 1;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// a "batch" of one file is no batch
|
|
162
|
+
for (let id = 0; id < next; id += 1) {
|
|
163
|
+
const members = plan.filter((e) => e.batch === id);
|
|
164
|
+
if (members.length === 1) Object.assign(members[0], { batch: null, degrade_step: 0 });
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
// 2. judge per block for high-tier files
|
|
168
|
+
() => {
|
|
169
|
+
judgePerBlock = true;
|
|
170
|
+
for (const e of plan) if (e.depth_chosen === 'dual') Object.assign(e, { depth_chosen: 'dual-block-judge', degrade_step: 2 });
|
|
171
|
+
},
|
|
172
|
+
// 3. standard: full ⇒ quick
|
|
173
|
+
() => {
|
|
174
|
+
for (const e of plan) if (e.depth_chosen === 'full' && e.tier === 'standard') Object.assign(e, { depth_chosen: 'quick', degrade_step: 3 });
|
|
175
|
+
},
|
|
176
|
+
// 4. high: A + B (+ judge) ⇒ one full + judge — the floor
|
|
177
|
+
() => {
|
|
178
|
+
for (const e of plan) {
|
|
179
|
+
if (e.depth_chosen === 'dual-block-judge') Object.assign(e, { depth_chosen: 'full+block-judge', degrade_step: 4 });
|
|
180
|
+
else if (e.depth_chosen === 'dual') Object.assign(e, { depth_chosen: 'full+judge', degrade_step: 4 });
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
while (chosen > budget && step < steps.length) {
|
|
185
|
+
steps[step]();
|
|
186
|
+
step += 1;
|
|
187
|
+
chosen = forecast(plan, judgePerBlock);
|
|
188
|
+
}
|
|
189
|
+
return { budget, raised, forecast_unconstrained: unconstrained, forecast_chosen: chosen, degrade_step: step, over_budget: chosen > budget, judge_per_block: judgePerBlock, plan };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The ledger rows of a plan: one `review.plan` per file, then one `review.budget` for the block.
|
|
194
|
+
* @param {BudgetPlan} result @param {{block: string, actual?: number | null}} opts
|
|
195
|
+
* @returns {Array<Record<string, any>>}
|
|
196
|
+
*/
|
|
197
|
+
export function budgetRows(result, { block, actual = null }) {
|
|
198
|
+
/** @type {Array<Record<string, any>>} */
|
|
199
|
+
const rows = result.plan.map((e) => ({
|
|
200
|
+
event: 'review.plan',
|
|
201
|
+
block,
|
|
202
|
+
file: e.file,
|
|
203
|
+
tier: e.tier,
|
|
204
|
+
depth_unconstrained: e.depth_unconstrained,
|
|
205
|
+
depth_chosen: e.depth_chosen,
|
|
206
|
+
degrade_step: e.degrade_step,
|
|
207
|
+
forecast_tokens: e.forecast_tokens,
|
|
208
|
+
context_mode: e.context_mode,
|
|
209
|
+
tokens_source: 'estimated',
|
|
210
|
+
}));
|
|
211
|
+
rows.push({
|
|
212
|
+
event: 'review.budget',
|
|
213
|
+
block,
|
|
214
|
+
budget: result.budget,
|
|
215
|
+
forecast_unconstrained: result.forecast_unconstrained,
|
|
216
|
+
forecast_chosen: result.forecast_chosen,
|
|
217
|
+
actual,
|
|
218
|
+
raised: result.raised,
|
|
219
|
+
over_budget: result.over_budget,
|
|
220
|
+
degrade_step: result.degrade_step,
|
|
221
|
+
});
|
|
222
|
+
if (result.over_budget) rows.push({ event: 'review.over_budget', block, budget: result.budget, forecast_chosen: result.forecast_chosen });
|
|
223
|
+
return rows;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Plan and write the rows (the ledger writer signs/stamps them). `blockOnly` writes the per-block
|
|
228
|
+
* rows only (`review.budget`, `review.over_budget`): the live path (B19, the worker's first ticket
|
|
229
|
+
* of a block) already gets one `review.plan` per reviewed file from the review engine, and a second
|
|
230
|
+
* set of forecast `review.plan` rows would double the report's planned-file count.
|
|
231
|
+
* @param {{files: BudgetFile[], cfg?: Record<string, any>, raised?: number, block: string, blockOnly?: boolean, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
|
|
232
|
+
* @returns {Promise<BudgetPlan>}
|
|
233
|
+
*/
|
|
234
|
+
export async function planAndRecord({ files, cfg, raised, block, blockOnly = false, writeRow }) {
|
|
235
|
+
const result = planBudget({ files, cfg, raised });
|
|
236
|
+
for (const row of budgetRows(result, { block })) {
|
|
237
|
+
if (blockOnly && row.event === 'review.plan') continue;
|
|
238
|
+
await writeRow(row);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packet context (plan §4.3, C10; block B12a).
|
|
3
|
+
*
|
|
4
|
+
* A reviewer sees the current file around the diff, never the project. The rule:
|
|
5
|
+
* - `whole`: the file at its current content has ≤ `review.context.whole_file_max_lines` (400)
|
|
6
|
+
* lines ⇒ the whole file, every line numbered;
|
|
7
|
+
* - `hunks`: otherwise each hunk with ± `review.context.hunk_context_lines` (40) lines of the
|
|
8
|
+
* current file (overlapping windows merge);
|
|
9
|
+
* - `minimal`: the budget fallback ⇒ each hunk with ± `review.context.min_context_lines` (10);
|
|
10
|
+
* - `recheck`: a round ≥ 2 packet (§4.11) ⇒ always the hunk form on the fix hunks, never the
|
|
11
|
+
* whole file.
|
|
12
|
+
* Lines are numbered with their line number in the CURRENT file, so a finding's `line_start`
|
|
13
|
+
* points at a real line. Pure functions: no I/O.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CONTEXT_MODES = Object.freeze(['whole', 'hunks', 'minimal', 'recheck']);
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_CONTEXT = Object.freeze({ whole_file_max_lines: 400, hunk_context_lines: 40, min_context_lines: 10 });
|
|
19
|
+
|
|
20
|
+
/** `@@ -a[,b] +c[,d] @@` — the part of a hunk header a reviewer must echo (no function context). */
|
|
21
|
+
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{header: string, oldStart: number, oldLines: number, newStart: number, newLines: number}} Hunk
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a unified diff of ONE file. The file header (`diff --git`, `index`, `---`, `+++`) is
|
|
29
|
+
* skipped by position — everything before the first `@@` — so a content line that starts with
|
|
30
|
+
* `++` or `--` is never mistaken for a header.
|
|
31
|
+
* @param {string} text
|
|
32
|
+
* @returns {{hunks: Hunk[], plusCount: number, minusCount: number}}
|
|
33
|
+
*/
|
|
34
|
+
export function parseDiff(text) {
|
|
35
|
+
const lines = typeof text === 'string' ? text.split('\n') : [];
|
|
36
|
+
const first = lines.findIndex((line) => line.startsWith('@@'));
|
|
37
|
+
/** @type {Hunk[]} */
|
|
38
|
+
const hunks = [];
|
|
39
|
+
let plusCount = 0;
|
|
40
|
+
let minusCount = 0;
|
|
41
|
+
if (first < 0) return { hunks, plusCount, minusCount };
|
|
42
|
+
for (const line of lines.slice(first)) {
|
|
43
|
+
if (line.startsWith('@@')) {
|
|
44
|
+
const m = HUNK_RE.exec(line);
|
|
45
|
+
if (!m) continue;
|
|
46
|
+
hunks.push({
|
|
47
|
+
header: m[0],
|
|
48
|
+
oldStart: Number(m[1]),
|
|
49
|
+
oldLines: m[2] === undefined ? 1 : Number(m[2]),
|
|
50
|
+
newStart: Number(m[3]),
|
|
51
|
+
newLines: m[4] === undefined ? 1 : Number(m[4]),
|
|
52
|
+
});
|
|
53
|
+
} else if (line.startsWith('+')) {
|
|
54
|
+
plusCount += 1;
|
|
55
|
+
} else if (line.startsWith('-')) {
|
|
56
|
+
minusCount += 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { hunks, plusCount, minusCount };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The file's lines (a final newline does not make an extra empty line).
|
|
64
|
+
* @param {string | null} content
|
|
65
|
+
* @returns {string[]}
|
|
66
|
+
*/
|
|
67
|
+
export function splitLines(content) {
|
|
68
|
+
if (typeof content !== 'string' || content.length === 0) return [];
|
|
69
|
+
const lines = content.split('\n');
|
|
70
|
+
if (lines.at(-1) === '') lines.pop();
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One window per hunk over the CURRENT file: `[newStart − radius, newStart + max(newLines, 1) − 1
|
|
76
|
+
* + radius]`, clipped to the file; overlapping or touching windows merge.
|
|
77
|
+
* @param {ReadonlyArray<Hunk>} hunks @param {number} totalLines @param {number} radius
|
|
78
|
+
* @returns {Array<{start: number, end: number}>} 1-based, inclusive.
|
|
79
|
+
*/
|
|
80
|
+
export function hunkWindows(hunks, totalLines, radius) {
|
|
81
|
+
if (totalLines === 0) return [];
|
|
82
|
+
const raw = hunks
|
|
83
|
+
.map((h) => {
|
|
84
|
+
const anchor = Math.max(1, h.newStart);
|
|
85
|
+
const last = anchor + Math.max(h.newLines, 1) - 1;
|
|
86
|
+
return { start: Math.max(1, anchor - radius), end: Math.min(totalLines, last + radius) };
|
|
87
|
+
})
|
|
88
|
+
.filter((w) => w.start <= w.end)
|
|
89
|
+
.sort((a, b) => a.start - b.start);
|
|
90
|
+
/** @type {Array<{start: number, end: number}>} */
|
|
91
|
+
const merged = [];
|
|
92
|
+
for (const w of raw) {
|
|
93
|
+
const prev = merged.at(-1);
|
|
94
|
+
if (prev && w.start <= prev.end + 1) prev.end = Math.max(prev.end, w.end);
|
|
95
|
+
else merged.push({ ...w });
|
|
96
|
+
}
|
|
97
|
+
return merged;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {Record<string, any> | undefined} cfg */
|
|
101
|
+
export function contextSettings(cfg) {
|
|
102
|
+
const c = cfg?.review?.context ?? {};
|
|
103
|
+
const pick = (/** @type {string} */ key) => (Number.isInteger(c[key]) && c[key] >= 0 ? c[key] : DEFAULT_CONTEXT[/** @type {keyof typeof DEFAULT_CONTEXT} */ (key)]);
|
|
104
|
+
return { whole_file_max_lines: pick('whole_file_max_lines'), hunk_context_lines: pick('hunk_context_lines'), min_context_lines: pick('min_context_lines') };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {object} Context
|
|
109
|
+
* @property {'whole' | 'hunks' | 'minimal' | 'recheck'} mode
|
|
110
|
+
* @property {string} text - the rendered context section body.
|
|
111
|
+
* @property {Array<{start: number, end: number}>} windows
|
|
112
|
+
* @property {number} lineCount - how many file lines the context carries.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the context for one file.
|
|
117
|
+
* @param {{file: string, content: string | null, hunks: ReadonlyArray<Hunk>, cfg?: Record<string, any>, mode?: 'auto' | 'minimal' | 'recheck'}} opts
|
|
118
|
+
* `mode: 'auto'` (default) applies the whole-file threshold; `minimal` and `recheck` force the
|
|
119
|
+
* hunk form with their radius.
|
|
120
|
+
* @returns {Context}
|
|
121
|
+
*/
|
|
122
|
+
export function buildContext({ file, content, hunks, cfg, mode = 'auto' }) {
|
|
123
|
+
const settings = contextSettings(cfg);
|
|
124
|
+
const lines = splitLines(content);
|
|
125
|
+
/** @type {Context['mode']} */
|
|
126
|
+
let chosen;
|
|
127
|
+
if (mode === 'minimal') chosen = 'minimal';
|
|
128
|
+
else if (mode === 'recheck') chosen = 'recheck';
|
|
129
|
+
else chosen = lines.length <= settings.whole_file_max_lines ? 'whole' : 'hunks';
|
|
130
|
+
|
|
131
|
+
if (chosen === 'whole') {
|
|
132
|
+
const windows = lines.length > 0 ? [{ start: 1, end: lines.length }] : [];
|
|
133
|
+
const head = `### ${file} (whole file, ${lines.length} lines)`;
|
|
134
|
+
return { mode: chosen, windows, lineCount: lines.length, text: [head, ...numbered(lines, 1, lines.length)].join('\n') };
|
|
135
|
+
}
|
|
136
|
+
const radius = chosen === 'minimal' ? settings.min_context_lines : settings.hunk_context_lines;
|
|
137
|
+
const windows = hunkWindows(hunks, lines.length, radius);
|
|
138
|
+
const parts = [];
|
|
139
|
+
let lineCount = 0;
|
|
140
|
+
for (const w of windows) {
|
|
141
|
+
parts.push(`### ${file} lines ${w.start}-${w.end}`, ...numbered(lines, w.start, w.end));
|
|
142
|
+
lineCount += w.end - w.start + 1;
|
|
143
|
+
}
|
|
144
|
+
if (windows.length === 0) parts.push(`### ${file} (no current content)`);
|
|
145
|
+
return { mode: chosen, windows, lineCount, text: parts.join('\n') };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string[]} lines @param {number} start @param {number} end - 1-based, inclusive.
|
|
150
|
+
* @returns {string[]}
|
|
151
|
+
*/
|
|
152
|
+
function numbered(lines, start, end) {
|
|
153
|
+
const out = [];
|
|
154
|
+
for (let n = start; n <= end; n += 1) out.push(`${n}| ${lines[n - 1]}`);
|
|
155
|
+
return out;
|
|
156
|
+
}
|