@popovych.co/witness 0.20.0 → 0.21.1
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/README.md +85 -16
- package/dist/arbiter.js +2 -2
- package/dist/calibrate.js +12 -6
- package/dist/cli.js +3 -0
- package/dist/config-file.js +195 -0
- package/dist/config.js +312 -262
- package/dist/context.js +8 -5
- package/dist/drift.js +1 -1
- package/dist/drive-report.js +5 -10
- package/dist/drive.js +1 -1
- package/dist/evidence.js +1 -2
- package/dist/gate.js +7 -20
- package/dist/gates/ship.js +2 -3
- package/dist/harness.js +6 -7
- package/dist/journal.js +5 -2
- package/dist/legacy-comments.js +30 -0
- package/dist/migration.js +177 -0
- package/dist/model.js +14 -24
- package/dist/pin.js +13 -0
- package/dist/runner.js +12 -26
- package/dist/settings.js +276 -0
- package/dist/ship.js +4 -8
- package/dist/slice.js +32 -19
- package/dist/steps.js +56 -33
- package/dist/verbs/calibrate.js +1 -1
- package/dist/verbs/check.js +8 -7
- package/dist/verbs/dashboard.js +2 -2
- package/dist/verbs/decide.js +2 -5
- package/dist/verbs/dispatch.js +1 -1
- package/dist/verbs/evidence.js +16 -12
- package/dist/verbs/init.js +28 -27
- package/dist/verbs/log.js +1 -12
- package/dist/verbs/migrate.js +201 -0
- package/dist/verbs/next.js +60 -36
- package/dist/verbs/start.js +1 -2
- package/dist/verbs/step-close.js +4 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +1 -1
- package/plugin/hooks/canon-guard.mjs +12 -1
- package/plugin/hooks/context-core.mjs +20 -10
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +1 -1
- package/plugin/skills/witness-decompose/SKILL.md +1 -1
- package/plugin/skills/witness-design/SKILL.md +4 -2
- package/plugin/skills/witness-implement/SKILL.md +3 -3
- package/plugin/skills/witness-plan/SKILL.md +1 -1
- package/plugin/skills/witness-ship/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -162,22 +162,91 @@ either.
|
|
|
162
162
|
|
|
163
163
|
## Configuration keys
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
|
173
|
-
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
165
|
+
`witness init` writes every setting into `witness.config.yaml`, each with a comment saying
|
|
166
|
+
what it does and what it accepts, and nothing is inherited: each gate states its own `model`
|
|
167
|
+
and `reviewers`, and the arbiter its own `arbiter.model`. A missing setting, an unknown key and
|
|
168
|
+
an invalid value all refuse, and the refusal says how to fix it. The table is generated from
|
|
169
|
+
the list the CLI reads (`src/settings.ts`).
|
|
170
|
+
|
|
171
|
+
<!-- settings:start -->
|
|
172
|
+
| Setting | What it does | Default |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `harness` | the judge: the agent CLI that runs this repo's gate reviewers and arbiter · claude-code \| pi — accepts: claude-code \| pi — the judge: which agent CLI runs this repo's gate reviewers and arbiter | `claude-code` |
|
|
175
|
+
| `paths.specs` | folder for specs, repo-relative · git mv the docs when you change it — accepts: a repo-relative directory | `specs` |
|
|
176
|
+
| `paths.plans` | folder for plans, repo-relative · git mv the docs when you change it — accepts: a repo-relative directory | `plans` |
|
|
177
|
+
| `paths.designs` | folder for approved designs, repo-relative · git mv the docs when you change it — accepts: a repo-relative directory | `designs` |
|
|
178
|
+
| `docs.conventions` | code docs handed to code-reviewer at implement and ship · file list, [] = none — accepts: a list of repo-relative file paths ([] = none) | `[]` |
|
|
179
|
+
| `docs.design` | design docs handed to design-critic and design-reviewer · file list, [] = none — accepts: a list of repo-relative file paths ([] = none) | `[]` |
|
|
180
|
+
| `criteria.runner` | how a spec's criteria run · a command with {id}, or full-suite — accepts: a filtered template containing {id}, or full-suite | `'npm test -- -t "@spec:{id}"'` |
|
|
181
|
+
| `criteria.report` | the junit report witness reads · junit:<glob>, or none · full-suite needs one — accepts: none, or junit:<glob> (only junit is supported) | `none` |
|
|
182
|
+
| `criteria.exclude` | extra globs never scanned for criteria tags · [] = none — accepts: a list of globs ([] = none) | `[]` |
|
|
183
|
+
| `ship.test` | the suite command ship runs · full-suite criteria run it too — accepts: a shell command | `npm test` |
|
|
184
|
+
| `ship.lint` | the lint command ship runs — accepts: a shell command | `npm run lint` |
|
|
185
|
+
| `ship.branch` | the branch witness ships into — accepts: a branch name | `main` |
|
|
186
|
+
| `ship.merge` | how ship merges its own PR under autonomy · merge \| squash \| rebase — accepts: merge \| squash \| rebase | `merge` |
|
|
187
|
+
| `ship.ciSettleMs` | ms green CI must hold still before an autonomous merge · 0–1800000 — accepts: an integer from 0 to 1800000 — milliseconds a green check set must hold before witness merges | `60000` |
|
|
188
|
+
| `autonomy` | on = the CLI settles its own stops and merges green PRs · on \| off · counts once committed — accepts: on \| off | `off` |
|
|
189
|
+
| `arbiter.model` | model for the arbiter that settles stops and checks recaps under autonomy · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
|
|
190
|
+
| `session.contextLimit` | % of the context window at which a session hands over to a fresh one · 1–100 — accepts: an integer from 1 to 100 — % of the session's context window at which witness hands over to a fresh session | `20` |
|
|
191
|
+
| `implement.stepsPerDispatch` | steps an implement session takes when its context fill can't be read · ≥ 1 — accepts: an integer >= 1 — steps handed to each fresh implement agent | `3` |
|
|
192
|
+
| `gates.reviewerTimeoutMs` | ms one reviewer, or the arbiter, may run before it is stopped (10 min) · ≥ 1 — accepts: an integer >= 1 — milliseconds each reviewer invocation may take | `600000` |
|
|
193
|
+
| `gates.decompose.model` | model for this gate's reviewers · exact id, [provider/]id[:thinking], no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
|
|
194
|
+
| `gates.decompose.reviewers` | reviewers this gate runs · one list, or one list per class (feature, fix, chore) — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `[slicing-critic]` |
|
|
195
|
+
| `gates.plan.model` | model for this gate's reviewers · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
|
|
196
|
+
| `gates.plan.reviewers` | reviewers this gate runs · one list, or one list per class — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `[plan-critic]` |
|
|
197
|
+
| `gates.design.model` | model for this gate's reviewers · runs only on specs marked ui — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
|
|
198
|
+
| `gates.design.reviewers` | reviewers this gate runs · one list, or one list per class — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `[design-critic]` |
|
|
199
|
+
| `gates.implement.model` | model for this gate's reviewers AND for the agent that writes the code — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
|
|
200
|
+
| `gates.implement.reviewers` | reviewers this gate runs · one list, or one list per class — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `{feature: [code-reviewer, silent-failure-hunter, type-design, pr-test, design-reviewer], fix: [code-reviewer, silent-failure-hunter, design-reviewer], chore: [code-reviewer]}` |
|
|
201
|
+
| `gates.ship.model` | model for this gate's reviewers · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
|
|
202
|
+
| `gates.ship.reviewers` | reviewers this gate runs · one list, or one list per class — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `[drift-reviewer, code-reviewer]` |
|
|
203
|
+
| `drive.parallel` | sessions drive runs at once, one per target · ≥ 1 — accepts: an integer >= 1 — sessions drive runs at once · one per target | `2` |
|
|
204
|
+
| `drive.parallelSuites` | let test runs in two worktrees overlap · true \| false — accepts: true \| false — whether test runs in two worktrees may overlap | `false` |
|
|
205
|
+
| `drive.sessionTimeoutMs` | ms one spawned session may run before it is stopped (1 h) · ≥ 1 — accepts: an integer >= 1 — milliseconds each spawned session may take before SIGTERM | `3600000` |
|
|
206
|
+
| `drive.caps.rounds` | gate rounds one target may run across budget resets, then it is parked · ≥ 1 — accepts: an integer >= 1 — gate rounds one target may run across budget resets before drive parks it | `16` |
|
|
207
|
+
| `drive.caps.upstream` | upstream resets one target may take, then it is parked · ≥ 1 — accepts: an integer >= 1 — upstream resets one target may take before drive parks it | `6` |
|
|
208
|
+
| `drive.caps.spawns` | sessions one target may take in one run, then it is parked · ≥ 1 — accepts: an integer >= 1 — sessions one target may take in one drive run | `24` |
|
|
209
|
+
| `drive.caps.wallClockMs` | ms of session time one target may take in one run (16 h), then it is parked · ≥ 1 — accepts: an integer >= 1 — milliseconds of session time one target may take in one drive run | `57600000` |
|
|
210
|
+
<!-- settings:end -->
|
|
211
|
+
|
|
212
|
+
**The judge (`harness`).** Which harness runs this repo's gate reviewers and its arbiter, is
|
|
213
|
+
measured by `calibrate`, and is probed by `check`. It is declared, never detected: `witness
|
|
214
|
+
init` writes the harness `--agent` names, else the one whose session runs `init`, else
|
|
215
|
+
`claude-code`, and says which. Session lines (`next`'s handoff, `dispatch`'s relay, `init
|
|
216
|
+
--agent auto`) resolve detection first, because those name the CLI you are about to type at.
|
|
217
|
+
Committed, so it binds every teammate's gates; `init --agent` never re-points it. An older file
|
|
218
|
+
without `harness:` is judged by whichever agent's session is open until `witness migrate` writes
|
|
219
|
+
it — `witness check` and `witness status` say so.
|
|
220
|
+
|
|
221
|
+
**The context limit (`session.contextLimit`).** The harness measures the fill; the CLI decides
|
|
222
|
+
where the journal carries the position — at a step close, after a revise, at a `witness ship`
|
|
223
|
+
exit and at `next` — and never mid-red. A session whose fill cannot be read says so (`context:
|
|
224
|
+
unmeasured (<reason>)`): an implement session then hands over by `implement.stepsPerDispatch`,
|
|
225
|
+
and nothing else hands over (DESIGN.md row 191).
|
|
226
|
+
|
|
227
|
+
**Autonomy (`autonomy`, `ship.merge`, `ship.ciSettleMs`).** Under `autonomy: on` the CLI settles
|
|
228
|
+
its own stops — through its recommendation or the arbiter it launches on `arbiter.model` — and
|
|
229
|
+
merges its own PRs once CI is green. A committed repo fact: it counts only once HEAD says it, and
|
|
230
|
+
the merge also needs the ship branch on origin to say it. `--manual` on a gate run keeps that
|
|
231
|
+
run's stop for a human, and three stops always reach one: the look of a new screen, an unmet plan
|
|
232
|
+
need and a trust grant. `witness ship` merges with `ship.merge` only once the ship gate is
|
|
233
|
+
approved and CI is green — never when the approval lapsed or GitHub refuses — and only after the
|
|
234
|
+
check set has held still for `ship.ciSettleMs`: gh's watch ends the moment nothing is pending, so
|
|
235
|
+
a check that registers late would otherwise be missed. Green means at least one check passed and
|
|
236
|
+
none failed, was cancelled or is pending.
|
|
237
|
+
|
|
238
|
+
**`drive`.** `drive.parallelSuites: false` makes test runs in two worktrees take turns, because
|
|
239
|
+
suites that boot servers or browsers collide. A target that reaches any `drive.caps.*` bound is
|
|
240
|
+
parked.
|
|
241
|
+
|
|
242
|
+
### Upgrading
|
|
243
|
+
|
|
244
|
+
After each witness upgrade, run `witness migrate --dry-run` to see what the new version adds or
|
|
245
|
+
changes, then `witness migrate`: it brings `witness.config.yaml` to the current schema, keeps
|
|
246
|
+
every value you chose and every comment you wrote, names each change, and commits the file. A
|
|
247
|
+
value still at an old default moves to the new one, and the output says so. Until you migrate,
|
|
248
|
+
an older file keeps working: witness reads it as `migrate` would, changes nothing in it, and warns
|
|
249
|
+
(`witness check` shows `older-schema`). A file from a newer witness refuses: upgrade witness.
|
|
181
250
|
|
|
182
251
|
There is no `provider:` key. `witness gate` spawns the DECLARED harness's headless
|
|
183
252
|
mode for every reviewer (Decisions 88, 105): claude-code renders bare Anthropic ids, pi
|
package/dist/arbiter.js
CHANGED
|
@@ -57,14 +57,14 @@ export async function arbitrate(ctx, root, cfg, req) {
|
|
|
57
57
|
if (!lensR.ok)
|
|
58
58
|
return lensR;
|
|
59
59
|
const lens = lensR.value;
|
|
60
|
-
const hxR = resolveJudge(ctx.env, cfg.
|
|
60
|
+
const hxR = resolveJudge(ctx.env, cfg.harness);
|
|
61
61
|
if (!hxR.ok)
|
|
62
62
|
return refuse(hxR.violations);
|
|
63
63
|
const harness = hxR.value.harness;
|
|
64
64
|
const localR = loadLocalConfig(root);
|
|
65
65
|
if (!localR.ok)
|
|
66
66
|
return localR;
|
|
67
|
-
const modelR = resolveModel(cfg, loadMatrix(root, harness.name));
|
|
67
|
+
const modelR = resolveModel(cfg, loadMatrix(root, harness.name), 'arbiter');
|
|
68
68
|
if (!modelR.ok)
|
|
69
69
|
return refuse(modelR.violations);
|
|
70
70
|
const chain = modelR.value.chain;
|
package/dist/calibrate.js
CHANGED
|
@@ -9,6 +9,8 @@ import { loadConfig } from './config.js';
|
|
|
9
9
|
import { changedFiles, diffBase, evidenceForDiff } from './evidence.js';
|
|
10
10
|
import { readDoc, splitDoc, writeDoc } from './fm.js';
|
|
11
11
|
import { git, stateCommit } from './gitio.js';
|
|
12
|
+
import { renderConfig } from './config-file.js';
|
|
13
|
+
import { defaultValues, withValues } from './settings.js';
|
|
12
14
|
import { ok, refuse, renderRefusal, v } from './refusal.js';
|
|
13
15
|
import { PROMPT_NAMES, invokeReviewer, parseVerdictText, resolvePrompt } from './reviewer.js';
|
|
14
16
|
import { findById, loadCanon } from './scan.js';
|
|
@@ -387,7 +389,7 @@ function captureCtx(cwd, env) {
|
|
|
387
389
|
const ctx = { cwd, env, isTTY: false, out: (l) => outs.push(l), err: (l) => errs.push(l), ask: async () => '' };
|
|
388
390
|
return { ctx, out: () => outs.join('\n'), err: () => errs.join('\n') };
|
|
389
391
|
}
|
|
390
|
-
export function seedScratchRepo(prefix) {
|
|
392
|
+
export function seedScratchRepo(prefix, harness) {
|
|
391
393
|
const root = mkdtempSync(join(tmpdir(), `cal-${prefix}-`));
|
|
392
394
|
git(root, 'init', '-b', 'main');
|
|
393
395
|
git(root, 'config', 'user.name', 'witness-calibration');
|
|
@@ -398,8 +400,12 @@ export function seedScratchRepo(prefix) {
|
|
|
398
400
|
// `tests/` arg is resolved as a module path on Node 24, and a glob is taken literally
|
|
399
401
|
// on Node 20 ("Could not find 'tests/**/*.test.mjs'": glob args landed in Node 21),
|
|
400
402
|
// which silently scores every implement sample 0. Node's default discovery already
|
|
401
|
-
// walks the repo for *.test.mjs and skips node_modules.
|
|
402
|
-
|
|
403
|
+
// walks the repo for *.test.mjs and skips node_modules. A full current file (row 193), so
|
|
404
|
+
// no calibration run prints the older-schema warning; every reviewer here is handed its
|
|
405
|
+
// model explicitly, so the prefilled pins change nothing.
|
|
406
|
+
renderConfig(withValues(defaultValues({ harness }), {
|
|
407
|
+
criteria: { runner: 'node --test --test-name-pattern "@spec:{id}"' },
|
|
408
|
+
})));
|
|
403
409
|
writeFileSync(join(root, '.gitignore'), '.witness/lock\n.witness/allow.json\n.witness/calibration.local.yaml\n.witness/worktrees/\n');
|
|
404
410
|
git(root, 'add', '-A');
|
|
405
411
|
git(root, 'commit', '-m', 'seed');
|
|
@@ -431,7 +437,7 @@ function firstCriterionTag(meta) {
|
|
|
431
437
|
return withTest ? String(withTest.test).replace(/^@spec:/, '') : '';
|
|
432
438
|
}
|
|
433
439
|
export async function runDecomposeSeed(ctx, harness, model, seed, extras) {
|
|
434
|
-
const { root } = seedScratchRepo(`decompose-${seed.id}
|
|
440
|
+
const { root } = seedScratchRepo(`decompose-${seed.id}`, harness.name);
|
|
435
441
|
const canonDir = join(seed.dir, 'canon');
|
|
436
442
|
if (existsSync(canonDir)) {
|
|
437
443
|
for (const f of readdirSync(canonDir))
|
|
@@ -520,7 +526,7 @@ export async function runDecomposeSeed(ctx, harness, model, seed, extras) {
|
|
|
520
526
|
return ok({ ok: true, why: 'first-try valid' });
|
|
521
527
|
}
|
|
522
528
|
export async function runPlanSeed(ctx, harness, model, seed, extras) {
|
|
523
|
-
const { root } = seedScratchRepo(`plan-${seed.id}
|
|
529
|
+
const { root } = seedScratchRepo(`plan-${seed.id}`, harness.name);
|
|
524
530
|
const recapText = readFileSync(join(seed.dir, 'recap.json'), 'utf8');
|
|
525
531
|
const recap = JSON.parse(recapText);
|
|
526
532
|
writeFileSync(join(root, '.cal-recap.json'), recapText);
|
|
@@ -601,7 +607,7 @@ export async function defaultAgent(ctx, harness, worktree, prompt) {
|
|
|
601
607
|
});
|
|
602
608
|
}
|
|
603
609
|
export async function runImplementSeed(ctx, harness, seed, agent) {
|
|
604
|
-
const { root } = seedScratchRepo(`implement-${seed.id}
|
|
610
|
+
const { root } = seedScratchRepo(`implement-${seed.id}`, harness.name);
|
|
605
611
|
cpSync(join(seed.dir, 'repo'), root, { recursive: true });
|
|
606
612
|
git(root, 'add', '-A');
|
|
607
613
|
git(root, 'commit', '-m', 'seed: repo');
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,8 @@ export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
|
|
|
10
10
|
const VERBS = {
|
|
11
11
|
recover: () => import('./verbs/recover.js'),
|
|
12
12
|
init: () => import('./verbs/init.js'),
|
|
13
|
+
// Row 193 §4.6. Brings witness.config.yaml to this CLI's schema, naming every change.
|
|
14
|
+
migrate: () => import('./verbs/migrate.js'),
|
|
13
15
|
recap: () => import('./verbs/recap.js'),
|
|
14
16
|
// Row 176. The human's ask, queued as state so `next` routes it and no session can lose it.
|
|
15
17
|
ask: () => import('./verbs/ask.js'),
|
|
@@ -70,6 +72,7 @@ const VERB_USAGE = {
|
|
|
70
72
|
gate: 'witness gate <decompose|plan|implement|ship|design> <target> [--fresh] [--manual]',
|
|
71
73
|
index: 'witness index',
|
|
72
74
|
init: 'witness init [--agent claude-code|pi|auto]',
|
|
75
|
+
migrate: 'witness migrate [--dry-run]',
|
|
73
76
|
log: 'witness log <stream> [--all] [--lineage]',
|
|
74
77
|
next: 'witness next',
|
|
75
78
|
read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Lexer, isCollection, isMap, isScalar, parseDocument } from 'yaml';
|
|
2
|
+
import { LEGACY_COMMENTS } from './legacy-comments.js';
|
|
3
|
+
import { CHANGE_CLASSES, FIRST_LINE, GROUP_HEADINGS, SCHEMA_COMMENT, SETTINGS, getIn, inlineValue, } from './settings.js';
|
|
4
|
+
import { sectionsOf } from './migration.js';
|
|
5
|
+
const EMPTY = { top: [], above: new Map(), end: [] };
|
|
6
|
+
// Witness's comments start at this column, or two spaces past a longer line (§4.5's layout).
|
|
7
|
+
const COMMENT_COLUMN = 31;
|
|
8
|
+
const withComment = (content, comment) => `${content.length < COMMENT_COLUMN ? content.padEnd(COMMENT_COLUMN) : `${content} `}# ${comment}`;
|
|
9
|
+
const classComment = (cls) => `for a ${cls}`;
|
|
10
|
+
export { inlineValue } from './settings.js';
|
|
11
|
+
// Every map in block style, every list in flow style, sections in SECTION_ORDER (which the
|
|
12
|
+
// list's own order follows), witness's comment at the end of every setting line and never on a
|
|
13
|
+
// section line: a comment there made the canon guard's old reader fall back to the defaults (P3).
|
|
14
|
+
export function renderConfig(values, comments = EMPTY) {
|
|
15
|
+
const out = [FIRST_LINE, ...comments.top, withComment(`schema: ${String(values.schema)}`, SCHEMA_COMMENT)];
|
|
16
|
+
const printed = new Set();
|
|
17
|
+
const above = (path, depth) => {
|
|
18
|
+
printed.add(path);
|
|
19
|
+
for (const line of comments.above.get(path) ?? [])
|
|
20
|
+
out.push(`${' '.repeat(depth)}${line}`);
|
|
21
|
+
};
|
|
22
|
+
let group;
|
|
23
|
+
const opened = new Set();
|
|
24
|
+
for (const s of SETTINGS) {
|
|
25
|
+
const val = getIn(values, s.path);
|
|
26
|
+
if (val === undefined)
|
|
27
|
+
continue;
|
|
28
|
+
if (s.group !== group) {
|
|
29
|
+
group = s.group;
|
|
30
|
+
out.push('', GROUP_HEADINGS[group]);
|
|
31
|
+
}
|
|
32
|
+
const parts = s.path.split('.');
|
|
33
|
+
for (let i = 1; i < parts.length; i++) {
|
|
34
|
+
const section = parts.slice(0, i).join('.');
|
|
35
|
+
if (opened.has(section))
|
|
36
|
+
continue;
|
|
37
|
+
opened.add(section);
|
|
38
|
+
above(section, i - 1);
|
|
39
|
+
out.push(`${' '.repeat(i - 1)}${parts[i - 1]}:`);
|
|
40
|
+
}
|
|
41
|
+
const depth = parts.length - 1;
|
|
42
|
+
const indent = ' '.repeat(depth);
|
|
43
|
+
const key = parts[depth];
|
|
44
|
+
above(s.path, depth);
|
|
45
|
+
if (s.shape.kind === 'battery' && !Array.isArray(val)) {
|
|
46
|
+
out.push(`${indent}${key}:`);
|
|
47
|
+
for (const cls of CHANGE_CLASSES) {
|
|
48
|
+
above(`${s.path}.${cls}`, depth + 1);
|
|
49
|
+
out.push(withComment(`${indent} ${cls}: ${inlineValue(val[cls])}`, classComment(cls)));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// A per-class comment whose map became a plain list sits above the list's line.
|
|
54
|
+
if (s.shape.kind === 'battery')
|
|
55
|
+
for (const cls of CHANGE_CLASSES)
|
|
56
|
+
above(`${s.path}.${cls}`, depth);
|
|
57
|
+
out.push(withComment(`${indent}${key}: ${inlineValue(val)}`, s.comment));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Nothing a human wrote is dropped: a line filed under a path this file no longer prints
|
|
61
|
+
// goes to the end rather than nowhere.
|
|
62
|
+
for (const [path, lines] of comments.above)
|
|
63
|
+
if (!printed.has(path))
|
|
64
|
+
out.push(...lines);
|
|
65
|
+
out.push(...comments.end);
|
|
66
|
+
return `${out.join('\n')}\n`;
|
|
67
|
+
}
|
|
68
|
+
// ── Who wrote which comment ──────────────────────────────────────────────────────────────────
|
|
69
|
+
const CURRENT_COMMENTS = new Set([
|
|
70
|
+
...SETTINGS.map((s) => s.comment),
|
|
71
|
+
...CHANGE_CLASSES.map(classComment),
|
|
72
|
+
SCHEMA_COMMENT,
|
|
73
|
+
FIRST_LINE.slice(1).trim(),
|
|
74
|
+
...Object.values(GROUP_HEADINGS).map((h) => h.slice(1).trim()),
|
|
75
|
+
]);
|
|
76
|
+
// Where a human line can be printed: every setting and section, and a per-class reviewer line.
|
|
77
|
+
const PRINTABLE = new Set([
|
|
78
|
+
...SETTINGS.map((s) => s.path),
|
|
79
|
+
...sectionsOf(new Set(SETTINGS.map((s) => s.path))),
|
|
80
|
+
...SETTINGS.filter((s) => s.shape.kind === 'battery').flatMap((s) => CHANGE_CLASSES.map((c) => `${s.path}.${c}`)),
|
|
81
|
+
]);
|
|
82
|
+
// A line above a key this file no longer prints (`gates.model`, retired at schema 2) moves to
|
|
83
|
+
// the head of that key's section, which is where the key sat in the old layout's terms.
|
|
84
|
+
function printableFor(path) {
|
|
85
|
+
for (let p = path; p.includes('.');) {
|
|
86
|
+
if (PRINTABLE.has(p))
|
|
87
|
+
return p;
|
|
88
|
+
const section = p.slice(0, p.lastIndexOf('.'));
|
|
89
|
+
const first = SETTINGS.find((s) => s.path.startsWith(`${section}.`));
|
|
90
|
+
if (first)
|
|
91
|
+
return first.path;
|
|
92
|
+
p = section;
|
|
93
|
+
}
|
|
94
|
+
return PRINTABLE.has(path) ? path : undefined;
|
|
95
|
+
}
|
|
96
|
+
// The lexer's own control tokens are not source text; every other token is, in order.
|
|
97
|
+
const CONTROL = /^[\x02\x18\x1f]$/;
|
|
98
|
+
export function readComments(text) {
|
|
99
|
+
const src = text.replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
100
|
+
const lineStarts = [0];
|
|
101
|
+
for (let i = 0; i < src.length; i++)
|
|
102
|
+
if (src[i] === '\n')
|
|
103
|
+
lineStarts.push(i + 1);
|
|
104
|
+
const lineOf = (offset) => {
|
|
105
|
+
let lo = 0;
|
|
106
|
+
let hi = lineStarts.length - 1;
|
|
107
|
+
while (lo < hi) {
|
|
108
|
+
const mid = (lo + hi + 1) >> 1;
|
|
109
|
+
if (lineStarts[mid] <= offset)
|
|
110
|
+
lo = mid;
|
|
111
|
+
else
|
|
112
|
+
hi = mid - 1;
|
|
113
|
+
}
|
|
114
|
+
return lo;
|
|
115
|
+
};
|
|
116
|
+
// Every key the file holds, block or flow: where it starts, its column, and the span of its
|
|
117
|
+
// value when that is a map or a list (a block map's span runs on over the comments that end
|
|
118
|
+
// it). Lists are values, not keys.
|
|
119
|
+
const keys = [];
|
|
120
|
+
const walk = (node, prefix) => {
|
|
121
|
+
if (!isMap(node))
|
|
122
|
+
return;
|
|
123
|
+
for (const pair of node.items) {
|
|
124
|
+
if (!isScalar(pair.key) || !pair.key.range)
|
|
125
|
+
continue;
|
|
126
|
+
const path = prefix ? `${prefix}.${String(pair.key.value)}` : String(pair.key.value);
|
|
127
|
+
const at = pair.key.range[0];
|
|
128
|
+
// Only a map or a list can hold a comment line: a scalar's span also runs over a deeper
|
|
129
|
+
// comment on the line after it, which sits between two keys, not inside the value.
|
|
130
|
+
const range = isCollection(pair.value) ? pair.value.range : undefined;
|
|
131
|
+
keys.push({ path, offset: at, line: lineOf(at), column: at - lineStarts[lineOf(at)], value: range ? [range[0], range[2]] : undefined });
|
|
132
|
+
walk(pair.value, path);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
walk(parseDocument(src).contents, '');
|
|
136
|
+
keys.sort((a, b) => a.offset - b.offset);
|
|
137
|
+
const human = { top: [], above: new Map(), end: [] };
|
|
138
|
+
let legacy = 0;
|
|
139
|
+
let moved = 0;
|
|
140
|
+
const file = (path, line) => human.above.set(path, [...(human.above.get(path) ?? []), line]);
|
|
141
|
+
let offset = 0;
|
|
142
|
+
for (const token of new Lexer().lex(src)) {
|
|
143
|
+
if (CONTROL.test(token))
|
|
144
|
+
continue;
|
|
145
|
+
const at = offset;
|
|
146
|
+
offset += token.length;
|
|
147
|
+
if (!token.startsWith('#'))
|
|
148
|
+
continue;
|
|
149
|
+
const line = lineOf(at);
|
|
150
|
+
const comment = token.trimEnd();
|
|
151
|
+
const body = comment.slice(1).trim();
|
|
152
|
+
const ownLine = src.slice(lineStarts[line], at).trim() === '';
|
|
153
|
+
if (LEGACY_COMMENTS.has(body) || CURRENT_COMMENTS.has(body)) {
|
|
154
|
+
// A line of its own vanishes; one on a setting's line, or the first line, is replaced.
|
|
155
|
+
if (ownLine && line !== 0 && LEGACY_COMMENTS.has(body))
|
|
156
|
+
legacy++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
let key;
|
|
160
|
+
if (ownLine) {
|
|
161
|
+
// A line of its own sits above the next key — unless it sits inside an earlier key's value
|
|
162
|
+
// (between a list's items, at the indented end of a map) and that next key is outside it:
|
|
163
|
+
// then it belongs to that value's key, wherever the rewrite moves the next one (final
|
|
164
|
+
// review I2). Indentation decides the map end: a block map's span runs on to the next key.
|
|
165
|
+
const column = at - lineStarts[line];
|
|
166
|
+
const next = keys.find((k) => k.offset > at);
|
|
167
|
+
const holder = keys.filter((k) => k.value && k.value[0] <= at && at < k.value[1] && k.column < column).at(-1);
|
|
168
|
+
key = holder && !(next && next.path.startsWith(`${holder.path}.`)) ? holder.path : next?.path;
|
|
169
|
+
if (key === undefined) {
|
|
170
|
+
human.end.push(comment);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (key === next?.path && key === keys[0]?.path) {
|
|
174
|
+
human.top.push(comment);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (key !== next?.path)
|
|
178
|
+
moved++;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// At the end of a line: the key that line opens, else the one whose value it sits inside.
|
|
182
|
+
key = keys.find((k) => k.line === line)?.path ?? keys.filter((k) => k.offset < at).at(-1)?.path;
|
|
183
|
+
moved++;
|
|
184
|
+
}
|
|
185
|
+
const target = key === undefined || key === 'schema' ? undefined : printableFor(key);
|
|
186
|
+
if (target === undefined)
|
|
187
|
+
human.top.push(comment);
|
|
188
|
+
else {
|
|
189
|
+
if (ownLine && target !== key)
|
|
190
|
+
moved++;
|
|
191
|
+
file(target, comment);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { human, legacy, moved };
|
|
195
|
+
}
|