simt-dev 0.2.0 → 0.2.2
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 +375 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +40 -0
- package/rules/stacks/deno.md +38 -0
- package/rules/stacks/dotnet.md +37 -0
- package/rules/stacks/elixir.md +41 -0
- package/rules/stacks/go.md +36 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +38 -0
- package/rules/stacks/rust.md +36 -0
- package/rules/stacks/swift.md +39 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +54 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +58 -25
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +37 -5
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +95 -69
- package/src/commands/init.mjs +100 -20
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +96 -60
- package/src/commands/skills.mjs +189 -86
- package/src/commands/skillsinstall.mjs +20 -5
- package/src/commands/sync.mjs +213 -15
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +107 -7
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +84 -7
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +290 -3
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +86 -9
- package/src/package-version.mjs +74 -0
- package/src/packs.mjs +182 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +335 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +139 -8
- package/src/projection/rules.mjs +308 -27
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +90 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// What a reader loads at session start, in bytes, counted rather than estimated.
|
|
2
|
+
//
|
|
3
|
+
// Review 0002 S38 measured this by hand - 90,042 B per turn across seven roots,
|
|
4
|
+
// 64,738 B of it a paragraph an earlier root had already loaded - and a number that
|
|
5
|
+
// costs a session to produce is a number nobody re-measures. This module is that
|
|
6
|
+
// measurement as code.
|
|
7
|
+
//
|
|
8
|
+
// What it will not do:
|
|
9
|
+
//
|
|
10
|
+
// 1. Estimate. Tokens are bytes divided by four and are labelled approximate: a
|
|
11
|
+
// tokenizer is a dependency this package does not have (ADR-012), and an
|
|
12
|
+
// exact-looking wrong number is worse than a labelled approximation.
|
|
13
|
+
// 2. Charge hand-written bytes to the tool. `generatedBytes` is what the
|
|
14
|
+
// projection wrote; `readerBytes` is what the reader reads past the marker. A
|
|
15
|
+
// repository with a 40 KB hand-written prefix has a context problem this tool
|
|
16
|
+
// does not have, and billing it to the budget would put the number on the
|
|
17
|
+
// wrong head.
|
|
18
|
+
// 3. Keep a second opinion about any harness. Paths, read limits and pointer
|
|
19
|
+
// behaviour all come from the surface table, which the writer already has to
|
|
20
|
+
// agree with; a loader list living here would drift from it.
|
|
21
|
+
// 4. Write anything. `compare` is read-only for the same reason - a measurement
|
|
22
|
+
// that can change what it measures is not a measurement.
|
|
23
|
+
|
|
24
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
25
|
+
import { basename, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { resolveSurfaces } from '../projection/surfaces.mjs';
|
|
28
|
+
import { parse } from '../projection/markers.mjs';
|
|
29
|
+
import { budgetFinding, DEFAULT_BUDGET_BYTES, readManifest } from '../projection/manifest.mjs';
|
|
30
|
+
import { extractFrontmatter } from '../projection/targets.mjs';
|
|
31
|
+
import { readLimitFindings } from '../projection/project.mjs';
|
|
32
|
+
import { TOOLS } from '../mcp/tools.mjs';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The ceiling review 0002 S38 set for one repository's always-on generated bytes.
|
|
36
|
+
*
|
|
37
|
+
* Read as 30,000 rather than 30 * 1024: it is a ceiling, and the conservative reading
|
|
38
|
+
* of "at most 30 KB" is the one that trips first. Plan 0003 R08 makes the budget a
|
|
39
|
+
* manifest field, and the manifest's default is the same number - so a repo that
|
|
40
|
+
* declares nothing is measured against the line the review drew, and a repo that
|
|
41
|
+
* declares something is measured against its own (ADR-072).
|
|
42
|
+
*/
|
|
43
|
+
export const ALWAYS_ON_BUDGET_BYTES = DEFAULT_BUDGET_BYTES;
|
|
44
|
+
|
|
45
|
+
/** Bytes per token for the approximation. Labelled approximate wherever it is printed. */
|
|
46
|
+
export const BYTES_PER_TOKEN = 4;
|
|
47
|
+
|
|
48
|
+
/** Tokens for a byte count. Rounded, and never presented as exact. */
|
|
49
|
+
export function tokens(bytes) {
|
|
50
|
+
return Math.round(bytes / BYTES_PER_TOKEN);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Bytes of `tools/list` the MCP surface answers with - the same for every repository,
|
|
55
|
+
* so it is reported beside the roots and never inside a root's total. Counting it per
|
|
56
|
+
* root would make each repository look over budget for a workspace-level cost.
|
|
57
|
+
*/
|
|
58
|
+
export function mcpTools() {
|
|
59
|
+
const list = TOOLS.map((t) => ({ name: t.name, description: t.description ?? '' }));
|
|
60
|
+
return { tools: TOOLS.length, bytes: Buffer.byteLength(JSON.stringify({ tools: list })) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Paragraphs, as the redundancy figure in review 0002 counted them: blank-line
|
|
65
|
+
* separated blocks, byte-exact after trimming. A different definition would produce a
|
|
66
|
+
* different number that could not be compared with the one this replaces.
|
|
67
|
+
*/
|
|
68
|
+
export function paragraphs(text) {
|
|
69
|
+
return text
|
|
70
|
+
.split(/\n[ \t]*\n/)
|
|
71
|
+
.map((block) => block.trim())
|
|
72
|
+
.filter((block) => block.length > 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** UTF-8 byte length. `text.length` counts UTF-16 units, which undercounts every
|
|
76
|
+
* non-ASCII character - and a rule file with an em dash in it is ordinary. */
|
|
77
|
+
function bytes(text) {
|
|
78
|
+
return Buffer.byteLength(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readText(path) {
|
|
82
|
+
try {
|
|
83
|
+
return await readFile(path, 'utf8');
|
|
84
|
+
} catch {
|
|
85
|
+
// ENOENT is the expected answer for a surface a repo selected but has not written
|
|
86
|
+
// yet, and a permission error is not a byte count either. Both mean "nothing to
|
|
87
|
+
// measure here", which the caller states as zero rather than as a failure.
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Files under a directory a surface declares, in a stable order.
|
|
94
|
+
*
|
|
95
|
+
* Recursive because Claude Code documents recursive discovery for `.claude/rules/`: a
|
|
96
|
+
* nested rule file loads for that reader, and a meter that did not walk into it would
|
|
97
|
+
* report a cost the reader does not pay - or hide one it does. The depth cap is not a
|
|
98
|
+
* guess about nesting; it is the bound that keeps a stray `node_modules` inside a
|
|
99
|
+
* tool-owned directory from turning a byte count into an unbounded walk.
|
|
100
|
+
*/
|
|
101
|
+
async function filesUnder(root, dir, suffix, depth = 0) {
|
|
102
|
+
let entries;
|
|
103
|
+
try {
|
|
104
|
+
entries = await readdir(join(root, dir), { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const names = entries.map((e) => e.name).sort();
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
if (name.startsWith('.')) continue;
|
|
112
|
+
const entry = entries.find((e) => e.name === name);
|
|
113
|
+
const path = `${dir}/${name}`;
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
if (depth >= 6) continue;
|
|
116
|
+
out.push(...(await filesUnder(root, `${path}`, suffix, depth + 1)));
|
|
117
|
+
} else if (entry.isFile() && (!suffix || name.endsWith(suffix))) {
|
|
118
|
+
out.push(path);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Frontmatter keys, as an object. One block, no nesting - which is all a SKILL.md has. */
|
|
125
|
+
function frontmatter(text) {
|
|
126
|
+
const block = extractFrontmatter(text);
|
|
127
|
+
const out = {};
|
|
128
|
+
if (!block) return out;
|
|
129
|
+
for (const line of block.split('\n').slice(1)) {
|
|
130
|
+
if (line === '---') break;
|
|
131
|
+
const at = line.indexOf(':');
|
|
132
|
+
if (at < 1) continue;
|
|
133
|
+
out[line.slice(0, at).trim()] = line
|
|
134
|
+
.slice(at + 1)
|
|
135
|
+
.trim()
|
|
136
|
+
.replace(/^["']|["']$/g, '');
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every document a selected surface puts in front of a reader, with what it costs.
|
|
142
|
+
*
|
|
143
|
+
* `channel` which kind of channel the bytes arrive through
|
|
144
|
+
* `file` the path, relative to the repository root, for the reader to check
|
|
145
|
+
* `text` the bytes a reader loads - the generated span for a region, the whole
|
|
146
|
+
* file for a rule file, only the frontmatter for a skill
|
|
147
|
+
* `readerBytes` what the reader reads, which for the primary file includes the
|
|
148
|
+
* hand-written bytes around the region */
|
|
149
|
+
async function documentsFor(root, surface) {
|
|
150
|
+
const out = [];
|
|
151
|
+
|
|
152
|
+
if (surface.primary) {
|
|
153
|
+
const text = await readText(join(root, surface.primary));
|
|
154
|
+
if (text !== null) {
|
|
155
|
+
const state = parse(text);
|
|
156
|
+
// A malformed region is reported as a finding with no byte count: the span
|
|
157
|
+
// between two markers that do not pair is not a region, and attributing the
|
|
158
|
+
// whole file to the projection would overstate what the tool wrote.
|
|
159
|
+
// `parse` hands back `before` and `after` as lines re-joined with `\n`, which is
|
|
160
|
+
// one byte shorter than the text they were cut from: the newline that joined
|
|
161
|
+
// them to the region belongs to neither. So `text - before - after` bills the
|
|
162
|
+
// projection for the blank line a person left above its own marker - in the one
|
|
163
|
+
// command whose entire claim is that it counts exactly. The span is the two
|
|
164
|
+
// markers and what is between them, and the byte count is that span's.
|
|
165
|
+
const open = state.before.length === 0 ? 0 : state.before.length + 1;
|
|
166
|
+
const close = state.after.length === 0 ? text.length : text.length - state.after.length - 1;
|
|
167
|
+
const span = state.status === 'ok' ? text.slice(open, close) : '';
|
|
168
|
+
const generated = bytes(span);
|
|
169
|
+
out.push({
|
|
170
|
+
channel: 'region',
|
|
171
|
+
file: surface.primary,
|
|
172
|
+
text: span,
|
|
173
|
+
generatedBytes: generated,
|
|
174
|
+
readerBytes: bytes(text),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (surface.rule) {
|
|
180
|
+
const dir = surface.rule.slice(0, surface.rule.lastIndexOf('/'));
|
|
181
|
+
const suffix = surface.rule.slice(surface.rule.lastIndexOf('.') + 1);
|
|
182
|
+
for (const file of await filesUnder(root, dir, `.${suffix}`)) {
|
|
183
|
+
const text = await readText(join(root, file));
|
|
184
|
+
if (text === null) continue;
|
|
185
|
+
out.push({ channel: 'rules', file, text, generatedBytes: bytes(text), readerBytes: bytes(text) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (surface.skills) {
|
|
190
|
+
// `skills` is the directory this tool writes; the documented paths are the
|
|
191
|
+
// alternatives a reader also accepts. A skill present in two of them loads once,
|
|
192
|
+
// so the first path in the table keeps the bytes - the same rule a pointer
|
|
193
|
+
// follows, and for the same reason: one reader, one copy of the text.
|
|
194
|
+
const dirs = [...new Set([surface.skills, ...(surface.documentedSkillsPaths ?? [])])];
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
for (const dir of dirs) {
|
|
197
|
+
for (const file of await filesUnder(root, dir, '.md')) {
|
|
198
|
+
if (!basename(file).startsWith('SKILL')) continue;
|
|
199
|
+
const text = await readText(join(root, file));
|
|
200
|
+
if (text === null) continue;
|
|
201
|
+
const meta = frontmatter(text);
|
|
202
|
+
const name = meta.name ?? basename(file);
|
|
203
|
+
const description = meta.description ?? '';
|
|
204
|
+
if (seen.has(name)) continue;
|
|
205
|
+
seen.add(name);
|
|
206
|
+
// Name and description only: that is what the Agent Skills spec has a reader
|
|
207
|
+
// load before it decides to open the body, so that is what costs a turn.
|
|
208
|
+
const head = description ? `${name}\n${description}` : name;
|
|
209
|
+
out.push({
|
|
210
|
+
channel: 'skills',
|
|
211
|
+
file,
|
|
212
|
+
text: head,
|
|
213
|
+
generatedBytes: bytes(head),
|
|
214
|
+
readerBytes: bytes(head),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One repository: what its readers load, and what of it this tool wrote. */
|
|
224
|
+
export async function measureRoot(root) {
|
|
225
|
+
const { manifest, exists, error } = await readManifest(root);
|
|
226
|
+
const resolved = resolveSurfaces(manifest, {});
|
|
227
|
+
const documents = [];
|
|
228
|
+
for (const surface of resolved.surfaces) {
|
|
229
|
+
documents.push(...(await documentsFor(root, surface)));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const generatedBytes = documents.reduce((sum, d) => sum + d.generatedBytes, 0);
|
|
233
|
+
const readerBytes = documents.reduce((sum, d) => sum + d.readerBytes, 0);
|
|
234
|
+
const budgetBytes =
|
|
235
|
+
manifest?.budget?.bytes !== undefined && Number.isFinite(manifest.budget.bytes)
|
|
236
|
+
? manifest.budget.bytes
|
|
237
|
+
: ALWAYS_ON_BUDGET_BYTES;
|
|
238
|
+
|
|
239
|
+
const byChannel = new Map();
|
|
240
|
+
for (const doc of documents) {
|
|
241
|
+
const row = byChannel.get(doc.channel) ?? { channel: doc.channel, files: 0, generatedBytes: 0, readerBytes: 0 };
|
|
242
|
+
row.files += 1;
|
|
243
|
+
row.generatedBytes += doc.generatedBytes;
|
|
244
|
+
row.readerBytes += doc.readerBytes;
|
|
245
|
+
byChannel.set(doc.channel, row);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const findings = [];
|
|
249
|
+
const readLimits = [];
|
|
250
|
+
if (manifest?.error) findings.push(`manifest: ${manifest.error}`);
|
|
251
|
+
const regionDoc = documents.find((d) => d.channel === 'region');
|
|
252
|
+
if (regionDoc) {
|
|
253
|
+
const text = await readText(join(root, regionDoc.file));
|
|
254
|
+
const state = parse(text ?? '');
|
|
255
|
+
if (state.status === 'malformed') findings.push(`${regionDoc.file}: generated region is malformed`);
|
|
256
|
+
// The gate's own finding, for the same reason `check` uses it: one answer to
|
|
257
|
+
// "does this reader reach the end of the region", so a meter that disagreed with
|
|
258
|
+
// the gate would be a second bug to find.
|
|
259
|
+
for (const finding of readLimitFindings(text ?? '', regionDoc.file)) {
|
|
260
|
+
findings.push(finding.detail);
|
|
261
|
+
readLimits.push({
|
|
262
|
+
file: finding.file,
|
|
263
|
+
reader: finding.reader,
|
|
264
|
+
limit: finding.limitBytes,
|
|
265
|
+
endByte: finding.endByte,
|
|
266
|
+
overBy: finding.overBy,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const name = root.split('/').filter(Boolean).pop() ?? root;
|
|
272
|
+
return {
|
|
273
|
+
name,
|
|
274
|
+
path: root,
|
|
275
|
+
adoption: !exists ? 'unmanaged' : error ? 'broken' : 'adopted',
|
|
276
|
+
surfaces: resolved.ids,
|
|
277
|
+
surfaceProblems: resolved.problems,
|
|
278
|
+
generatedBytes,
|
|
279
|
+
readerBytes,
|
|
280
|
+
// `readerBytes` is what a reader pays for this repository's file; `tokens` is an
|
|
281
|
+
// approximation of it and is reported as such wherever it is printed.
|
|
282
|
+
tokens: tokens(readerBytes),
|
|
283
|
+
generatedTokens: tokens(generatedBytes),
|
|
284
|
+
// The repository's own number, not the constant. The constant is the default a repo
|
|
285
|
+
// gets if it never declares one, which is the same line the review drew — but a repo
|
|
286
|
+
// that measures its rules differently has said so, and a measurement that overrode
|
|
287
|
+
// that would be reporting a violation of a line the repository never agreed to
|
|
288
|
+
// (ADR-072).
|
|
289
|
+
budget: {
|
|
290
|
+
bytes: budgetBytes,
|
|
291
|
+
declared: manifest?.budget?.bytes !== undefined,
|
|
292
|
+
over: generatedBytes > budgetBytes,
|
|
293
|
+
overBy: Math.max(0, generatedBytes - budgetBytes),
|
|
294
|
+
// The finding the gate would raise, produced by the same function, so the report
|
|
295
|
+
// and the gate cannot word the same fact two ways. It is carried rather than
|
|
296
|
+
// raised: R08 calls the cut non-blocking, and this command never fails on a
|
|
297
|
+
// measurement (ADR-064).
|
|
298
|
+
finding: budgetFinding(generatedBytes, manifest?.budget, manifest?.mode),
|
|
299
|
+
},
|
|
300
|
+
channels: [...byChannel.values()],
|
|
301
|
+
readLimits,
|
|
302
|
+
findings,
|
|
303
|
+
documents,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Fill in the redundancy numbers for a set of roots, in the order given.
|
|
309
|
+
*
|
|
310
|
+
* Three questions, one walk: bytes that repeat a paragraph an earlier ROOT loaded
|
|
311
|
+
* (`duplicateBytes` - the figure review 0002 quoted), bytes that repeat a paragraph
|
|
312
|
+
* an earlier DOCUMENT in the same root loaded (`repeatBytes` - what a per-rule file
|
|
313
|
+
* repeating the region costs), and the bytes neither does (`uniqueBytes`).
|
|
314
|
+
*
|
|
315
|
+
* Order decides ownership, so it is the caller's: `discover` returns repositories
|
|
316
|
+
* sorted by path, and the first root to carry a paragraph keeps it. Two runs over the
|
|
317
|
+
* same workspace therefore produce the same two numbers, which is the only reason the
|
|
318
|
+
* first of them is worth keeping.
|
|
319
|
+
*/
|
|
320
|
+
export function annotate(rows) {
|
|
321
|
+
const seenAcrossRoots = new Set();
|
|
322
|
+
for (const row of rows) {
|
|
323
|
+
const seenInRoot = new Set();
|
|
324
|
+
let uniqueBytes = 0;
|
|
325
|
+
let duplicateBytes = 0;
|
|
326
|
+
let repeatBytes = 0;
|
|
327
|
+
for (const doc of row.documents) {
|
|
328
|
+
for (const block of paragraphs(doc.text)) {
|
|
329
|
+
const size = bytes(block);
|
|
330
|
+
if (seenInRoot.has(block)) {
|
|
331
|
+
repeatBytes += size;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
seenInRoot.add(block);
|
|
335
|
+
if (seenAcrossRoots.has(block)) {
|
|
336
|
+
duplicateBytes += size;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
seenAcrossRoots.add(block);
|
|
340
|
+
uniqueBytes += size;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
row.redundancy = {
|
|
344
|
+
blockBytes: uniqueBytes + duplicateBytes + repeatBytes,
|
|
345
|
+
uniqueBytes,
|
|
346
|
+
duplicateBytes,
|
|
347
|
+
repeatBytes,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return rows;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The workspace totals. `generatedBytes` is the denominator the review used, so the
|
|
354
|
+
* percentage beside it means what the 72% meant. */
|
|
355
|
+
export function totals(rows) {
|
|
356
|
+
const sum = (pick) => rows.reduce((n, r) => n + pick(r), 0);
|
|
357
|
+
const generatedBytes = sum((r) => r.generatedBytes);
|
|
358
|
+
const duplicateBytes = sum((r) => r.redundancy?.duplicateBytes ?? 0);
|
|
359
|
+
const repeatBytes = sum((r) => r.redundancy?.repeatBytes ?? 0);
|
|
360
|
+
return {
|
|
361
|
+
roots: rows.length,
|
|
362
|
+
generatedBytes,
|
|
363
|
+
readerBytes: sum((r) => r.readerBytes),
|
|
364
|
+
tokens: sum((r) => r.generatedTokens),
|
|
365
|
+
blockBytes: sum((r) => r.redundancy?.blockBytes ?? 0),
|
|
366
|
+
uniqueBytes: sum((r) => r.redundancy?.uniqueBytes ?? 0),
|
|
367
|
+
duplicateBytes,
|
|
368
|
+
repeatBytes,
|
|
369
|
+
overBudget: rows.filter((r) => r.budget.over).map((r) => r.name),
|
|
370
|
+
};
|
|
371
|
+
}
|