simt-dev 0.7.1 → 0.8.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 +36 -1
- package/README.md +169 -80
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +3 -1
- package/rules/mdc/intake-questions.mdc +8 -0
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +7 -5
- package/src/argv.mjs +19 -0
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +155 -113
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +12 -26
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +17 -535
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +15 -1037
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +116 -971
- package/src/bench/run.mjs +164 -881
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +11 -13
- package/src/ci/dependabot.mjs +25 -55
- package/src/commands/agent.mjs +1 -9
- package/src/commands/agenthook.mjs +66 -35
- package/src/commands/bench.mjs +2 -2
- package/src/commands/ci.mjs +11 -13
- package/src/commands/docs.mjs +88 -0
- package/src/commands/gate.mjs +10 -13
- package/src/commands/harness.mjs +3 -3
- package/src/commands/help.mjs +44 -3
- package/src/commands/hooks.mjs +20 -20
- package/src/commands/ignore.mjs +11 -7
- package/src/commands/impact.mjs +4 -8
- package/src/commands/index.mjs +49 -9
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +122 -104
- package/src/commands/lease.mjs +67 -18
- package/src/commands/map.mjs +10 -5
- package/src/commands/mcp.mjs +2 -17
- package/src/commands/repoindex.mjs +8 -8
- package/src/commands/rules.mjs +8 -4
- package/src/commands/shared.mjs +12 -16
- package/src/commands/skills.mjs +5 -6
- package/src/commands/skillscompile.mjs +3 -3
- package/src/commands/skillsinstall.mjs +5 -4
- package/src/commands/slice.mjs +199 -64
- package/src/commands/spec.mjs +78 -62
- package/src/commands/status.mjs +24 -12
- package/src/commands/sync.mjs +14 -14
- package/src/commands/tests.mjs +12 -12
- package/src/commands/tokens.mjs +19 -24
- package/src/commands/upgrade.mjs +8 -4
- package/src/commands/why.mjs +6 -13
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/decisions/index.mjs +3 -2
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +2 -1
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +5 -3
- package/src/hooks/dispatcher.mjs +26 -32
- package/src/hooks/gates.mjs +12 -2
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +2 -11
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +16 -11
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +124 -17
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +49 -9
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -1005
- package/src/{bench → measure}/corpus.mjs +1 -1
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/output/index.mjs +122 -31
- package/src/packs/manage.mjs +15 -9
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/manifest.mjs +10 -435
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +16 -547
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +13 -1
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +0 -7
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +8 -12
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +1 -1
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +3 -1
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/src/bench/tasks.mjs +0 -36
package/src/ledger/index.mjs
CHANGED
|
@@ -6,11 +6,9 @@
|
|
|
6
6
|
// no longer proves anything about what is on disk now.
|
|
7
7
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
-
import {
|
|
10
|
-
import { appendFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
9
|
+
import { appendFile, copyFile, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
11
10
|
import { tmpdir } from 'node:os';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { promisify } from 'node:util';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
14
12
|
|
|
15
13
|
import {
|
|
16
14
|
errorSignature,
|
|
@@ -19,11 +17,11 @@ import {
|
|
|
19
17
|
recordFailThenFix,
|
|
20
18
|
} from '../memory/lessons.mjs';
|
|
21
19
|
import { TREE_EXCLUDES, localRel, readStateFile } from '../state.mjs';
|
|
20
|
+
import { appendEvent } from '../state/events.mjs';
|
|
21
|
+
import { LEDGER_FILE_LEGACY, LEDGER_FILE_REL } from '../name.mjs';
|
|
22
|
+
import { git, gitText } from '../sys/git.mjs';
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
export const LEDGER_FILE = localRel('ledger.jsonl');
|
|
26
|
-
const LEDGER_FILE_LEGACY = '.simt/ledger.jsonl';
|
|
24
|
+
export const LEDGER_FILE = localRel(LEDGER_FILE_REL);
|
|
27
25
|
|
|
28
26
|
/** Phrases that claim a run happened. `unverified` and `not verified` are removed
|
|
29
27
|
* first, because both contain the word and neither is a claim. Trailers
|
|
@@ -33,6 +31,33 @@ const CLAIM =
|
|
|
33
31
|
/\btests?\s+pass(?:ed|ing)?\b|\btest\s+suite\s+(?:is\s+)?green\b|\bsuite\s+is\s+green\b|\bnpm\s+test\s+pass(?:es|ed)?\b|\bverified\b/i;
|
|
34
32
|
const TRAILER = /^(signed-off-by|co-authored-by|reviewed-by|acked-by|tested-by|fixes|closes)\s*:/i;
|
|
35
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Copy the repository's real index into a private one, and answer its path or null.
|
|
36
|
+
*
|
|
37
|
+
* The private index exists so `git add -A` cannot stage the author's work. Building it from
|
|
38
|
+
* nothing meant every path was hashed on every gate run, commit-msg check, `evidence` call
|
|
39
|
+
* and `pr-body`: on a 20k-file tree that is the whole cost of the command (Q75). Seeding it
|
|
40
|
+
* from the index git already maintains turns the hash into a no-op for every unchanged
|
|
41
|
+
* path, and the `add -A` afterwards only has the worktree diff left to do.
|
|
42
|
+
*
|
|
43
|
+
* A repository with no index yet (a fresh `git init`) is not an error: there is nothing to
|
|
44
|
+
* copy, and the add builds it from empty exactly as before.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} root
|
|
47
|
+
* @param {string} target the private index file to populate
|
|
48
|
+
* @returns {Promise<string|null>} the source index, or null when there is none
|
|
49
|
+
*/
|
|
50
|
+
export async function seedIndex(root, target) {
|
|
51
|
+
try {
|
|
52
|
+
const stdout = await gitText(root, ['rev-parse', '--git-path', 'index']);
|
|
53
|
+
const from = resolve(root, stdout ?? '');
|
|
54
|
+
await copyFile(from, target);
|
|
55
|
+
return from;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
/**
|
|
37
62
|
* The tree of everything in the worktree, without staging any of it.
|
|
38
63
|
*
|
|
@@ -44,6 +69,8 @@ export async function treeId(root) {
|
|
|
44
69
|
const index = join(dir, 'index');
|
|
45
70
|
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
46
71
|
try {
|
|
72
|
+
// Seed before the add: the add then only hashes what actually changed (Q75).
|
|
73
|
+
await seedIndex(root, index);
|
|
47
74
|
// Machine-local state is a record of the tree, so it cannot be part of the
|
|
48
75
|
// tree it names. `git add -A` would take it once the file exists, and the
|
|
49
76
|
// line would prove a tree that stopped existing when the line was written
|
|
@@ -55,9 +82,8 @@ export async function treeId(root) {
|
|
|
55
82
|
// carry only the paths git does not already ignore.
|
|
56
83
|
const ignored = await ignoredSet(root, TREE_EXCLUDES);
|
|
57
84
|
const excludes = TREE_EXCLUDES.filter((p) => !ignored.has(p)).map((p) => `:(exclude)${p}`);
|
|
58
|
-
await
|
|
59
|
-
|
|
60
|
-
return stdout.trim();
|
|
85
|
+
await git(root, ['add', '-A', '--', '.', ...excludes], { env });
|
|
86
|
+
return await gitText(root, ['write-tree'], { env });
|
|
61
87
|
} finally {
|
|
62
88
|
await rm(dir, { recursive: true, force: true });
|
|
63
89
|
}
|
|
@@ -74,10 +100,7 @@ export async function treeId(root) {
|
|
|
74
100
|
*/
|
|
75
101
|
async function ignoredSet(root, paths) {
|
|
76
102
|
try {
|
|
77
|
-
|
|
78
|
-
// option, so a stdin-fed `check-ignore` waits for input that never comes
|
|
79
|
-
// and the promise never settles.
|
|
80
|
-
const { stdout } = await exec('git', ['check-ignore', ...paths], { cwd: root });
|
|
103
|
+
const stdout = await git(root, ['check-ignore', ...paths]);
|
|
81
104
|
return new Set(String(stdout).split('\n').filter(Boolean));
|
|
82
105
|
} catch {
|
|
83
106
|
// `check-ignore` exits 1 when nothing is ignored - the common case, not an
|
|
@@ -95,8 +118,7 @@ async function ignoredSet(root, paths) {
|
|
|
95
118
|
* Null when git cannot answer; the caller then falls back to the worktree tree. */
|
|
96
119
|
export async function indexTreeId(root) {
|
|
97
120
|
try {
|
|
98
|
-
const
|
|
99
|
-
const tree = String(stdout ?? '').trim();
|
|
121
|
+
const tree = await gitText(root, ['write-tree']);
|
|
100
122
|
return /^[0-9a-f]{40}$/.test(tree) ? tree : null;
|
|
101
123
|
} catch {
|
|
102
124
|
return null;
|
|
@@ -116,8 +138,10 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
|
|
|
116
138
|
...(typeof gate === 'string' ? { gate } : {}),
|
|
117
139
|
...(typeof kind === 'string' ? { kind } : {}),
|
|
118
140
|
cmd,
|
|
141
|
+
command: cmd,
|
|
119
142
|
exit,
|
|
120
143
|
ms,
|
|
144
|
+
wallMs: ms,
|
|
121
145
|
outputDigest: `sha256-${createHash('sha256').update(output).digest('hex')}`,
|
|
122
146
|
summary: summarise(output, exit),
|
|
123
147
|
...(exit !== 0
|
|
@@ -129,6 +153,14 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
|
|
|
129
153
|
};
|
|
130
154
|
await mkdir(join(root, '.simt', 'local'), { recursive: true });
|
|
131
155
|
await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
|
|
156
|
+
// The same record in the log every other store writes to (Q73): a tailing consumer has
|
|
157
|
+
// one file to follow, and a ledger line lost to rotation is still recoverable here.
|
|
158
|
+
await appendEvent(root, {
|
|
159
|
+
kind: 'gate.finished',
|
|
160
|
+
tree: worktreeTree,
|
|
161
|
+
subject: typeof cmd === 'string' ? cmd : null,
|
|
162
|
+
data: { ...(gate === null ? {} : { gate }), ...(kind === null ? {} : { kind }), line },
|
|
163
|
+
});
|
|
132
164
|
|
|
133
165
|
if (exit === 0 && typeof cmd === 'string') {
|
|
134
166
|
let pendingFail = null;
|
|
@@ -145,9 +177,84 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
|
|
|
145
177
|
}
|
|
146
178
|
}
|
|
147
179
|
|
|
180
|
+
await rotateLedger(root).catch(() => {});
|
|
148
181
|
return line;
|
|
149
182
|
}
|
|
150
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Read only the end of the ledger.
|
|
186
|
+
*
|
|
187
|
+
* `recordRun` and every evidence check read the whole file, and the file grows without a
|
|
188
|
+
* bound: a repository that runs a suite a hundred times a day pays for a year of lines on
|
|
189
|
+
* every commit (Q75). A tail read answers the only question those callers ask - what
|
|
190
|
+
* happened recently on which trees - in bounded time.
|
|
191
|
+
*
|
|
192
|
+
* The first line of the slice is dropped unless the slice starts at byte 0, because a byte
|
|
193
|
+
* offset lands mid-line and half a JSON object is not evidence.
|
|
194
|
+
*/
|
|
195
|
+
export async function readLedgerTail(root, { entries = 200, bytes = 128 * 1024 } = {}) {
|
|
196
|
+
const path = join(root, LEDGER_FILE);
|
|
197
|
+
let text = '';
|
|
198
|
+
let partial = false;
|
|
199
|
+
try {
|
|
200
|
+
const st = await stat(path);
|
|
201
|
+
const start = Math.max(0, st.size - bytes);
|
|
202
|
+
partial = start > 0;
|
|
203
|
+
const handle = await open(path, 'r');
|
|
204
|
+
try {
|
|
205
|
+
const buffer = Buffer.alloc(st.size - start);
|
|
206
|
+
await handle.read(buffer, 0, buffer.length, start);
|
|
207
|
+
text = buffer.toString('utf8');
|
|
208
|
+
} finally {
|
|
209
|
+
await handle.close();
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code !== 'ENOENT') throw error;
|
|
213
|
+
return { entries: [], errors: [], partial: false };
|
|
214
|
+
}
|
|
215
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
216
|
+
if (partial && lines.length > 0) lines.shift();
|
|
217
|
+
const parsed = [];
|
|
218
|
+
const errors = [];
|
|
219
|
+
for (const raw of lines) {
|
|
220
|
+
try {
|
|
221
|
+
const line = JSON.parse(raw);
|
|
222
|
+
if (typeof line.tree === 'string' && typeof line.exit === 'number') parsed.push(line);
|
|
223
|
+
else errors.push('a line has no tree or exit');
|
|
224
|
+
} catch (error) {
|
|
225
|
+
errors.push(`a line does not parse (${error.message})`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { entries: parsed.slice(-entries), errors, partial };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Keep the ledger bounded: past `maxLines`, the older half moves to `<ledger>.1`.
|
|
233
|
+
*
|
|
234
|
+
* Rotation rather than deletion because the ledger is evidence: an archived line is still
|
|
235
|
+
* readable, and the event log holds the same record independently (Q73). Keeping half
|
|
236
|
+
* rather than `maxLines` means rotation happens every `maxLines/2` appends instead of on
|
|
237
|
+
* every one.
|
|
238
|
+
*/
|
|
239
|
+
export async function rotateLedger(root, { maxLines = 5000 } = {}) {
|
|
240
|
+
const path = join(root, LEDGER_FILE);
|
|
241
|
+
let text;
|
|
242
|
+
try {
|
|
243
|
+
text = await readFile(path, 'utf8');
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error.code === 'ENOENT') return { rotated: false };
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
249
|
+
if (lines.length <= maxLines) return { rotated: false };
|
|
250
|
+
const keep = Math.max(1, Math.floor(maxLines / 2));
|
|
251
|
+
const archived = lines.slice(0, lines.length - keep);
|
|
252
|
+
const kept = lines.slice(lines.length - keep);
|
|
253
|
+
await writeFile(join(root, `${LEDGER_FILE}.1`), `${archived.join('\n')}\n`, 'utf8');
|
|
254
|
+
await writeFile(path, `${kept.join('\n')}\n`, 'utf8');
|
|
255
|
+
return { rotated: true, archived: archived.length, kept: kept.length };
|
|
256
|
+
}
|
|
257
|
+
|
|
151
258
|
/** Every well-formed line. A line that does not parse is returned as an error so a
|
|
152
259
|
* damaged ledger cannot look empty. */
|
|
153
260
|
export async function readLedger(root) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// One page of a list result, and the cursor that asks for the next one.
|
|
2
|
+
//
|
|
3
|
+
// MCP lets a server answer a list method in pages, and a client that ignores `nextCursor`
|
|
4
|
+
// must still see a usable first page. This server has never paginated, which was fine
|
|
5
|
+
// while every list fit in one reply and stops being fine the moment a workspace brings
|
|
6
|
+
// dozens of repositories into `resources/list`: a client that renders what it is given
|
|
7
|
+
// then silently shows a subset of the estate (review 0003 Q66).
|
|
8
|
+
//
|
|
9
|
+
// The cursor is the offset, rendered as a string. The specification calls it opaque, and
|
|
10
|
+
// the client must not parse it, but it has to be stable across calls: an index into a list
|
|
11
|
+
// that is rebuilt in a deterministic order is, and a hash of the last item's name is not -
|
|
12
|
+
// two items can share one.
|
|
13
|
+
|
|
14
|
+
/** How many entries one reply carries. */
|
|
15
|
+
export const PAGE_SIZE = 50;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The page a cursor asks for, or `null` when the cursor is not one this server issued.
|
|
19
|
+
*
|
|
20
|
+
* A cursor outside the list is not clamped to the last page: a client that walked past the
|
|
21
|
+
* end has a stale cursor, and silently repeating the tail hides that the list changed
|
|
22
|
+
* under it.
|
|
23
|
+
*/
|
|
24
|
+
export function paginate(items, cursor, size = PAGE_SIZE) {
|
|
25
|
+
const text = cursor === undefined || cursor === null || cursor === '' ? '0' : String(cursor);
|
|
26
|
+
// Only the shape this server writes: a decimal offset. `Number` accepts '0x10' and ' ',
|
|
27
|
+
// and accepting a cursor nobody issued is how pagination silently repeats or skips.
|
|
28
|
+
if (!/^\d+$/.test(text)) return null;
|
|
29
|
+
const start = Number(text);
|
|
30
|
+
const page = items.slice(start, start + size);
|
|
31
|
+
const next = start + size < items.length ? String(start + size) : null;
|
|
32
|
+
return { items: page, nextCursor: next };
|
|
33
|
+
}
|