simt-dev 0.2.1 → 0.3.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 +239 -0
- package/bin/simt-dev.mjs +21 -2
- 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/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/package.json +2 -1
- 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 +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -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 +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +55 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +359 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +301 -0
- package/src/commands/help.mjs +73 -2
- package/src/commands/index.mjs +67 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/plan.mjs +99 -0
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +169 -0
- package/src/compare/row.mjs +73 -2
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +228 -20
- package/src/mcp/tools.mjs +372 -1
- package/src/measure/context.mjs +542 -0
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/manifest.mjs +114 -3
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +365 -22
- package/src/projection/surfaces.mjs +155 -23
- package/src/projection/targets.mjs +37 -0
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
package/src/monorepo.mjs
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Packages inside a repository, from the files that declare them.
|
|
2
|
+
//
|
|
3
|
+
// A monorepo is one repository with several packages in it, and every tool that
|
|
4
|
+
// reads a repository reads only the root: `fingerprint(root)` sees the root's
|
|
5
|
+
// markers, `plan(root)` writes the root's region, and a reader working in
|
|
6
|
+
// `apps/web` inherits whatever the root happened to be. The fix is not "a
|
|
7
|
+
// monorepo is two repositories" — it is one root projection plus a nested one
|
|
8
|
+
// per package, each carrying the profiles that package selects and the root does
|
|
9
|
+
// not (ADR-066).
|
|
10
|
+
//
|
|
11
|
+
// Detection is a table, not code per ecosystem (ADR-013): each row names the
|
|
12
|
+
// files that DECLARE members and how to read the member list out of them. Two
|
|
13
|
+
// rows are deliberately absent and their reason is recorded in ADR-066: `nx.json`
|
|
14
|
+
// declares no member list at all (its projects are computed by running `nx`), and
|
|
15
|
+
// a Gradle `include(...)` is written in the settings file rather than a manifest
|
|
16
|
+
// this table already parses. A row that could only be read by executing the
|
|
17
|
+
// toolchain is not a row: a projection that depended on running the build would
|
|
18
|
+
// be a projection this repository cannot reproduce.
|
|
19
|
+
|
|
20
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
/** Packaged trees are not packages: their directories are dependencies. */
|
|
24
|
+
const SKIP_DIRS = new Set(['node_modules', 'target', 'vendor', 'dist', 'build', '.git', '.venv', 'venv']);
|
|
25
|
+
|
|
26
|
+
/** What makes a directory a package once a pattern has matched it. */
|
|
27
|
+
const PACKAGE_MARKERS = ['package.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pyproject.toml', 'mix.exs'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The declarations this tool reads. `patterns` reads a list of repo-relative
|
|
31
|
+
* directory patterns out of the named file.
|
|
32
|
+
*
|
|
33
|
+
* `read` returns patterns in the order the file lists them, because the reader
|
|
34
|
+
* reviewing the generated files is entitled to see them in the order they wrote
|
|
35
|
+
* them, and a sort would hide a duplicate that the reader can act on.
|
|
36
|
+
*/
|
|
37
|
+
const DECLARATIONS = [
|
|
38
|
+
{
|
|
39
|
+
ecosystem: 'node',
|
|
40
|
+
file: 'package.json',
|
|
41
|
+
evidence: 'workspaces field',
|
|
42
|
+
read: async (text) => {
|
|
43
|
+
let pkg;
|
|
44
|
+
try {
|
|
45
|
+
pkg = JSON.parse(text);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const w = pkg.workspaces;
|
|
50
|
+
if (Array.isArray(w)) return w;
|
|
51
|
+
if (w && Array.isArray(w.packages)) return w.packages;
|
|
52
|
+
return [];
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
ecosystem: 'node',
|
|
57
|
+
file: 'pnpm-workspace.yaml',
|
|
58
|
+
evidence: 'packages list',
|
|
59
|
+
read: async (text) => {
|
|
60
|
+
// A YAML list under `packages:`. Only the flat list form is read: a pnpm
|
|
61
|
+
// workspace that needs a full YAML parser to declare its members is a
|
|
62
|
+
// workspace whose declaration this tool must not guess at, and
|
|
63
|
+
// `packages:` with nothing under it declares none.
|
|
64
|
+
const lines = text.split('\n');
|
|
65
|
+
const out = [];
|
|
66
|
+
let inside = false;
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (/^packages:\s*$/.test(line)) {
|
|
69
|
+
inside = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!inside) continue;
|
|
73
|
+
const item = /^\s+-\s*(.+?)\s*$/.exec(line);
|
|
74
|
+
if (item) out.push(stripQuotes(item[1]));
|
|
75
|
+
else if (/^\S/.test(line)) inside = false;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
ecosystem: 'node',
|
|
82
|
+
file: 'turbo.json',
|
|
83
|
+
evidence: 'workspaces.packages list',
|
|
84
|
+
read: async (text) => {
|
|
85
|
+
let cfg;
|
|
86
|
+
try {
|
|
87
|
+
cfg = JSON.parse(text);
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
const w = cfg.workspaces;
|
|
92
|
+
return w && Array.isArray(w.packages) ? w.packages : [];
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
ecosystem: 'rust',
|
|
97
|
+
file: 'Cargo.toml',
|
|
98
|
+
evidence: '[workspace] members list',
|
|
99
|
+
read: async (text) => {
|
|
100
|
+
const members = tomlList(text, 'workspace', 'members');
|
|
101
|
+
return members;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
ecosystem: 'go',
|
|
106
|
+
file: 'go.work',
|
|
107
|
+
evidence: 'use list',
|
|
108
|
+
read: async (text) => {
|
|
109
|
+
const use = tomlList(text, '', 'use');
|
|
110
|
+
return use;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
ecosystem: 'jvm',
|
|
115
|
+
file: 'settings.gradle',
|
|
116
|
+
evidence: 'include list',
|
|
117
|
+
read: async (text) => gradleIncludes(text),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
ecosystem: 'jvm',
|
|
121
|
+
file: 'settings.gradle.kts',
|
|
122
|
+
evidence: 'include list',
|
|
123
|
+
read: async (text) => gradleIncludes(text),
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `include(":apps:web", ":core")` and its multi-line form, as paths.
|
|
129
|
+
*
|
|
130
|
+
* Gradle project paths are colon-separated and rooted at a colon, so `:apps:web`
|
|
131
|
+
* names the directory `apps/web`. That translation is all this reads: the arguments
|
|
132
|
+
* are literals, and a build that computes its includes from a variable is a build
|
|
133
|
+
* this tool does not evaluate - a directory with a marker file and no include is
|
|
134
|
+
* simply not discovered, which is the same honest absence every other unreadable
|
|
135
|
+
* declaration produces.
|
|
136
|
+
*/
|
|
137
|
+
function gradleIncludes(text) {
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const match of text.matchAll(/\binclude\s*\(([^)]*)\)/g)) {
|
|
140
|
+
for (const quoted of match[1].matchAll(/["']([^"']+)["']/g)) {
|
|
141
|
+
const path = quoted[1].replace(/^:/, '').split(':').filter(Boolean).join('/');
|
|
142
|
+
if (path !== '') out.push(path);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stripQuotes(value) {
|
|
149
|
+
return value.replace(/^["']/, '').replace(/["']$/, '');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One table's key, read as a list of paths.
|
|
154
|
+
*
|
|
155
|
+
* Every shape this tool needs is a flat list — `members = ["a", "b"]`,
|
|
156
|
+
* `members = [\n "a",\n]`, `use (\n ./api\n ./cmd/x\n)` — so the reader is a
|
|
157
|
+
* line scan with two states rather than a parser. Paths are quoted in TOML and
|
|
158
|
+
* bare in a `go.work`, so both are accepted; a comment is stripped, because
|
|
159
|
+
* `use ./api # the api` declares one path and not two.
|
|
160
|
+
*
|
|
161
|
+
* `table` is `''` for a file with no tables (go.work), or the table name. A key
|
|
162
|
+
* this shape does not find returns an empty list: a declaration this tool cannot
|
|
163
|
+
* read is one it does not guess at.
|
|
164
|
+
*/
|
|
165
|
+
function tomlList(text, table, key) {
|
|
166
|
+
const out = [];
|
|
167
|
+
let inTable = table === '';
|
|
168
|
+
let collecting = false;
|
|
169
|
+
for (const raw of text.split('\n')) {
|
|
170
|
+
const line = stripComment(raw);
|
|
171
|
+
if (/^\s*\[/.test(line)) {
|
|
172
|
+
inTable = table !== '' && line.trim() === `[${table}]`;
|
|
173
|
+
collecting = false;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!inTable) continue;
|
|
177
|
+
if (collecting) {
|
|
178
|
+
out.push(...pathTokens(line));
|
|
179
|
+
if (line.includes(']') || line.includes(')')) collecting = false;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
// Two spellings, because `go.work` uses one and `Cargo.toml` the other:
|
|
183
|
+
// `members = ["crates/a"]` and a bare `use (` opening a list of paths.
|
|
184
|
+
const assigned = /^\s*([A-Za-z_.-]+)\s*=\s*(.*)$/.exec(line);
|
|
185
|
+
const opened = /^\s*([A-Za-z_.-]+)\s*\((.*)$/.exec(line);
|
|
186
|
+
const kv = assigned ?? opened;
|
|
187
|
+
if (kv === null || kv[1] !== key) continue;
|
|
188
|
+
const rest = (assigned !== null ? kv[2] : `(${kv[2]}`).trim();
|
|
189
|
+
if (rest === '[]' || rest === '') continue;
|
|
190
|
+
// `use (` and `members = [` both open a list that ends on a later line.
|
|
191
|
+
if (/[[(]$/.test(rest)) {
|
|
192
|
+
out.push(...pathTokens(rest));
|
|
193
|
+
collecting = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
out.push(...pathTokens(rest));
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stripComment(line) {
|
|
202
|
+
const hash = line.indexOf('#');
|
|
203
|
+
return hash === -1 ? line : line.slice(0, hash);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The paths on one line of a list, quoted or bare, comma-separated. */
|
|
207
|
+
function pathTokens(line) {
|
|
208
|
+
const quoted = [...line.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
|
|
209
|
+
if (quoted.length > 0) return quoted;
|
|
210
|
+
return line
|
|
211
|
+
.replace(/[[\]()]/g, ' ')
|
|
212
|
+
.split(',')
|
|
213
|
+
.map((s) => s.trim())
|
|
214
|
+
.filter((s) => s !== '' && !s.startsWith('='));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Expand one declaration pattern into the directories it names.
|
|
219
|
+
*
|
|
220
|
+
* `*` matches within a path segment and `**` matches any depth. Both are what
|
|
221
|
+
* every one of these ecosystems writes, and a pattern syntax narrower than the
|
|
222
|
+
* declarations it reads is a detector that silently finds fewer packages than
|
|
223
|
+
* the repository has.
|
|
224
|
+
*/
|
|
225
|
+
async function expand(root, pattern) {
|
|
226
|
+
const segments = pattern.split('/').filter((s) => s !== '' && s !== '.');
|
|
227
|
+
if (segments.length === 0) return [];
|
|
228
|
+
let dirs = [root];
|
|
229
|
+
for (const seg of segments) {
|
|
230
|
+
const next = [];
|
|
231
|
+
for (const dir of dirs) {
|
|
232
|
+
let entries;
|
|
233
|
+
try {
|
|
234
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
235
|
+
} catch {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (!entry.isDirectory()) continue;
|
|
240
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
|
|
241
|
+
if (matches(seg, entry.name)) next.push(join(dir, entry.name));
|
|
242
|
+
// `**` also admits what is below it, so a member pattern like
|
|
243
|
+
// `packages/**` finds `packages/a` and `packages/a/inner`.
|
|
244
|
+
if (seg === '**') {
|
|
245
|
+
next.push(join(dir, entry.name));
|
|
246
|
+
next.push(...(await expand(join(dir, entry.name), '**')));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
dirs = [...new Set(next)];
|
|
251
|
+
}
|
|
252
|
+
return dirs;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** One segment's match: `*` is any run of characters, everything else is literal. */
|
|
256
|
+
function matches(segment, name) {
|
|
257
|
+
if (segment === '**') return true;
|
|
258
|
+
if (!segment.includes('*')) return segment === name;
|
|
259
|
+
const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
|
|
260
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Every package this repository declares, in the order the declarations name
|
|
265
|
+
* them and then in path order, deduplicated.
|
|
266
|
+
*
|
|
267
|
+
* A pattern that names a directory with no package marker in it is not a
|
|
268
|
+
* package, and neither is a pattern that escapes the repository: a declaration
|
|
269
|
+
* like `../other-repo/packages/*` is a path this tool must refuse to follow,
|
|
270
|
+
* because everything this tool projects has to resolve inside the repository
|
|
271
|
+
* that received it.
|
|
272
|
+
*
|
|
273
|
+
* @returns {Promise<{packages: {path: string, ecosystem: string, evidence: string}[],
|
|
274
|
+
* notes: string[]}>}
|
|
275
|
+
*/
|
|
276
|
+
export async function discoverPackages(root) {
|
|
277
|
+
const found = new Map();
|
|
278
|
+
const notes = [];
|
|
279
|
+
|
|
280
|
+
const declared = [];
|
|
281
|
+
for (const decl of DECLARATIONS) {
|
|
282
|
+
let text;
|
|
283
|
+
try {
|
|
284
|
+
text = await readFile(join(root, decl.file), 'utf8');
|
|
285
|
+
} catch {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const patterns = await decl.read(text);
|
|
289
|
+
if (patterns.length === 0) continue;
|
|
290
|
+
declared.push(`${decl.file} (${decl.evidence})`);
|
|
291
|
+
for (const pattern of patterns) {
|
|
292
|
+
const negated = stripQuotes(pattern).startsWith('!');
|
|
293
|
+
const clean = stripQuotes(pattern).replace(/^!/, '').replace(/\/+$/, '');
|
|
294
|
+
if (clean === '') {
|
|
295
|
+
notes.push(`${decl.file} names "${pattern}", which is not a path; ignored`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (clean.startsWith('..')) {
|
|
299
|
+
notes.push(`${decl.file} names "${pattern}", which is outside this repository; ignored`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
for (const dir of await expand(root, clean)) {
|
|
303
|
+
const rel = relativeTo(root, dir);
|
|
304
|
+
if (rel === '') continue;
|
|
305
|
+
// `!apps/legacy` excludes what an earlier pattern admitted, which is the
|
|
306
|
+
// only way pnpm and yarn express "this member is not built any more".
|
|
307
|
+
if (negated) {
|
|
308
|
+
found.delete(rel);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!(await isPackage(dir))) continue;
|
|
312
|
+
if (found.has(rel)) continue;
|
|
313
|
+
found.set(rel, { path: rel, ecosystem: decl.ecosystem, evidence: decl.evidence });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
packages: [...found.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
|
320
|
+
notes,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** A directory is a package when something a build tool would install lives in it. */
|
|
325
|
+
async function isPackage(dir) {
|
|
326
|
+
for (const marker of PACKAGE_MARKERS) {
|
|
327
|
+
try {
|
|
328
|
+
await readFile(join(dir, marker));
|
|
329
|
+
return true;
|
|
330
|
+
} catch {
|
|
331
|
+
// not here
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function relativeTo(root, dir) {
|
|
338
|
+
return dir.slice(root.length).replace(/^\//, '');
|
|
339
|
+
}
|
package/src/output/index.mjs
CHANGED
|
@@ -28,6 +28,24 @@ const REMEDY = {
|
|
|
28
28
|
[EXIT.REGISTRY]: `run: ${CLI} graph lint`,
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether failures are reported as JSON.
|
|
33
|
+
*
|
|
34
|
+
* A failure is a result: the caller asked a question and the answer is "no, and
|
|
35
|
+
* here is why". Written to stderr only, a caller parsing stdout under `--json`
|
|
36
|
+
* gets an empty document and has to believe the exit code, which tells it that
|
|
37
|
+
* something went wrong and nothing about what. So under `--json` the failure is
|
|
38
|
+
* the result and it goes where results go.
|
|
39
|
+
*
|
|
40
|
+
* The default answers "plain text" because that is what every run before the flags
|
|
41
|
+
* were parsed is, and those runs happen before anybody could have asked.
|
|
42
|
+
*/
|
|
43
|
+
let jsonFailure = false;
|
|
44
|
+
|
|
45
|
+
export function jsonFailMode(on = false) {
|
|
46
|
+
jsonFailure = on;
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
/**
|
|
32
50
|
* Report a failure and return its exit code. Callers `return fail(...)` so the
|
|
33
51
|
* code is impossible to forget.
|
|
@@ -38,6 +56,10 @@ export function fail(message, code = EXIT.FAIL, remedy) {
|
|
|
38
56
|
// a reader in a consumer repo got a remedy naming a file that does not exist
|
|
39
57
|
// where they are. Every remedy here is something the reader can run.
|
|
40
58
|
const resolved = remedy ?? REMEDY[code] ?? `run: ${CLI} --help`;
|
|
59
|
+
if (jsonFailure) {
|
|
60
|
+
emitJson({ ok: false, error: message, remedy: resolved, exitCode: code });
|
|
61
|
+
return code;
|
|
62
|
+
}
|
|
41
63
|
process.stderr.write(`ERROR ${code} ${message}\nREMEDY ${resolved}\n`);
|
|
42
64
|
return code;
|
|
43
65
|
}
|
|
@@ -84,8 +106,62 @@ export function emit(text, { maxBytes = 0, stream = process.stdout } = {}) {
|
|
|
84
106
|
return true;
|
|
85
107
|
}
|
|
86
108
|
|
|
87
|
-
|
|
88
|
-
|
|
109
|
+
/**
|
|
110
|
+
* The shape of every JSON payload, in the payload.
|
|
111
|
+
*
|
|
112
|
+
* A reader parsing `--json` from an agent has no other way to detect that a field moved
|
|
113
|
+
* or was renamed between releases: the parse still succeeds, and the reader's next lookup
|
|
114
|
+
* returns undefined. The number is in every payload rather than in the documentation
|
|
115
|
+
* because documentation is the thing that goes stale without failing anything, and the
|
|
116
|
+
* failure this prevents is silent.
|
|
117
|
+
*
|
|
118
|
+
* Bumped when a payload changes in a way a reader would notice. Adding a field is not
|
|
119
|
+
* that; removing, renaming or retyping one is.
|
|
120
|
+
*/
|
|
121
|
+
export const SCHEMA_VERSION = 1;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether the next payload is indented.
|
|
125
|
+
*
|
|
126
|
+
* Set once per run from the flags, rather than threaded through every call site: the
|
|
127
|
+
* alternative is twenty-five call sites each remembering to pass a value they do not
|
|
128
|
+
* otherwise care about, and the one that forgets is the one a reader pipes somewhere
|
|
129
|
+
* important. The default answers "is a person reading", which is the right answer
|
|
130
|
+
* before anybody sets it.
|
|
131
|
+
*/
|
|
132
|
+
let prettyDefault = process.stdout.isTTY === true;
|
|
133
|
+
|
|
134
|
+
export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
|
|
135
|
+
prettyDefault = pretty;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Emit JSON, compact unless a human is reading it.
|
|
140
|
+
*
|
|
141
|
+
* Indentation is for a person, and an agent pays for it on every call: the same document
|
|
142
|
+
* is a third again larger, and the reader is `JSON.parse`, not an eye. So the default is
|
|
143
|
+
* compact, and `--pretty` asks for the readable form. The one exception is a TTY, where
|
|
144
|
+
* the only consumer is a person: the compact document saves them nothing and costs them
|
|
145
|
+
* the diff they were about to read.
|
|
146
|
+
*/
|
|
147
|
+
export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = 0 } = {}) {
|
|
148
|
+
const payload =
|
|
149
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) && value.schemaVersion === undefined
|
|
150
|
+
? { schemaVersion: SCHEMA_VERSION, ...value }
|
|
151
|
+
: value;
|
|
152
|
+
const text = `${pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)}\n`;
|
|
153
|
+
// The same ceiling and the same announcement `emit` applies: a reader who asked for a
|
|
154
|
+
// bounded answer gets a bounded answer and is told how much is missing, rather than a
|
|
155
|
+
// half-parsed document it believes is whole.
|
|
156
|
+
if (maxBytes > 0 && Buffer.byteLength(text) > maxBytes) {
|
|
157
|
+
const slice = Buffer.from(text).subarray(0, maxBytes).toString('utf8');
|
|
158
|
+
const cut = slice.lastIndexOf('\n');
|
|
159
|
+
stream.write(cut > 0 ? `${slice.slice(0, cut)}\n` : slice);
|
|
160
|
+
stream.write(`TRUNCATED ${Buffer.byteLength(text) - maxBytes} bytes omitted, raise maxBytes\n`);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
stream.write(text);
|
|
164
|
+
return true;
|
|
89
165
|
}
|
|
90
166
|
|
|
91
167
|
/**
|
package/src/packs.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Shared rule sets, vendored into the repository that uses them.
|
|
2
|
+
//
|
|
3
|
+
// A pack is one answer to a question fourteen repositories kept asking separately:
|
|
4
|
+
// "several repositories share one set of rules, and it must change in one place."
|
|
5
|
+
// What a pack is NOT is a remote fetch. The content lives in the adopting
|
|
6
|
+
// repository at `.simt-dev/packs/<name>/`, and the manifest records a digest of
|
|
7
|
+
// it. Reading it from anywhere else would make the rendered region depend on a
|
|
8
|
+
// path that does not resolve in the receiving repository - which is the single
|
|
9
|
+
// failure this whole tool exists to prevent, and it would be introduced by the
|
|
10
|
+
// feature meant to remove duplication.
|
|
11
|
+
//
|
|
12
|
+
// `graph/skills.graph.json` is deliberately not part of a pack. ADR-061 ruled
|
|
13
|
+
// the registry to be workspace data; a second source for it would need its own
|
|
14
|
+
// resolution ruling, and inventing one here would be how that gets settled by
|
|
15
|
+
// accident.
|
|
16
|
+
|
|
17
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { readManifest } from './projection/manifest.mjs';
|
|
22
|
+
import { CLI, MANIFEST_FILE } from './name.mjs';
|
|
23
|
+
|
|
24
|
+
/** Where a pack's files live, relative to a repository root. */
|
|
25
|
+
export const PACK_DIR = '.simt-dev/packs';
|
|
26
|
+
|
|
27
|
+
// A pack name is a directory name under `PACK_DIR`, and it ends up in a path the
|
|
28
|
+
// tool builds from manifest content. Restricted to the characters a directory
|
|
29
|
+
// name can hold without escaping, so a hand-edited manifest cannot point the
|
|
30
|
+
// reader's renderer at something outside the repository.
|
|
31
|
+
const NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @returns {string|null} the name if it is usable, else why it is not.
|
|
35
|
+
*/
|
|
36
|
+
function packNameProblem(name) {
|
|
37
|
+
if (typeof name !== 'string' || name.length === 0) return 'a pack name must be a non-empty string';
|
|
38
|
+
if (!NAME.test(name)) return `\`${name}\` is not a usable pack name: lowercase letters, digits, dot, dash and underscore`;
|
|
39
|
+
if (name === '.' || name === '..') return `\`${name}\` is not a usable pack name`;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The digest of one pack directory: every file, relative path and bytes, in
|
|
45
|
+
* sorted path order. Same shape as the rule provenance digest on purpose - a
|
|
46
|
+
* reader who has seen one digest in a manifest has seen the other.
|
|
47
|
+
*
|
|
48
|
+
* The paths are pack-relative, so a pack moved to another repository hashes the
|
|
49
|
+
* same. That is the property that makes the digest a statement about CONTENT
|
|
50
|
+
* rather than about where the reader happens to keep it.
|
|
51
|
+
*
|
|
52
|
+
* @returns {Promise<string>} 40 hex chars, or `''` for a directory that is not there.
|
|
53
|
+
*/
|
|
54
|
+
export async function packDigest(dir) {
|
|
55
|
+
const files = [];
|
|
56
|
+
const walk = async (d, prefix = '') => {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
64
|
+
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
65
|
+
if (entry.isDirectory()) await walk(join(d, entry.name), rel);
|
|
66
|
+
else if (entry.isFile()) files.push(rel);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
await walk(dir);
|
|
70
|
+
files.sort();
|
|
71
|
+
|
|
72
|
+
const hash = createHash('sha256');
|
|
73
|
+
for (const rel of files) {
|
|
74
|
+
const bytes = await readFile(join(dir, rel));
|
|
75
|
+
hash.update(`${rel}:${bytes.length}:`);
|
|
76
|
+
hash.update(bytes);
|
|
77
|
+
}
|
|
78
|
+
return hash.digest('hex').slice(0, 40);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the packs a repository declares, and report the ones it cannot use.
|
|
83
|
+
*
|
|
84
|
+
* A declared pack with no directory, or whose bytes no longer match the digest,
|
|
85
|
+
* is a finding rather than a silent omission: a repo whose region renders
|
|
86
|
+
* without half its rules is a repo whose reader was told something false, and the
|
|
87
|
+
* next `sync` would overwrite the truth on disk with the shorter region.
|
|
88
|
+
*
|
|
89
|
+
* `present` and `ok` are separate questions on purpose. A pack whose bytes do not
|
|
90
|
+
* match its pin is still PROJECTED - otherwise the remedy the finding names,
|
|
91
|
+
* `sync --yes`, would delete every rule the pack carries, which is the opposite of
|
|
92
|
+
* what a reader who ran the remedy meant. What is questioned is the pin, and the
|
|
93
|
+
* gate stays red until it is re-recorded; what is missing is the directory, and
|
|
94
|
+
* there is nothing to project.
|
|
95
|
+
*
|
|
96
|
+
* The remedy is `git checkout -- <dir>`, because the pack is vendored: git has
|
|
97
|
+
* the bytes this manifest's digest names. `sync` cannot help - it would render
|
|
98
|
+
* the region without the pack and call it current - so the remedy does not name
|
|
99
|
+
* it as the repair and does name it as the thing to run afterwards.
|
|
100
|
+
*
|
|
101
|
+
* @returns {Promise<{packs: {name: string, dir: string, source: string, version: string, digest: string, declared: string, ok: boolean}[], findings: {file: string, status: string, detail: string, remedy: string}[]}>}
|
|
102
|
+
*/
|
|
103
|
+
export async function resolvePacks(root) {
|
|
104
|
+
const findings = [];
|
|
105
|
+
const packs = [];
|
|
106
|
+
const { manifest, exists, error } = await readManifest(root);
|
|
107
|
+
if (!exists || error !== null || manifest === null) return { packs, findings };
|
|
108
|
+
|
|
109
|
+
const declared = Array.isArray(manifest.packs) ? manifest.packs : [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
|
|
112
|
+
for (const entry of declared) {
|
|
113
|
+
const name = entry?.name;
|
|
114
|
+
const problem = packNameProblem(name);
|
|
115
|
+
if (problem !== null) {
|
|
116
|
+
findings.push({
|
|
117
|
+
file: MANIFEST_FILE,
|
|
118
|
+
status: 'pack-name-invalid',
|
|
119
|
+
detail: problem,
|
|
120
|
+
remedy: `correct or remove the entry, then: ${CLI} sync --yes`,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (seen.has(name)) {
|
|
125
|
+
findings.push({
|
|
126
|
+
file: MANIFEST_FILE,
|
|
127
|
+
status: 'pack-declared-twice',
|
|
128
|
+
detail: `pack \`${name}\` is declared twice; the second declaration is read as the first`,
|
|
129
|
+
remedy: `remove the duplicate entry, then: ${CLI} sync --yes`,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
seen.add(name);
|
|
134
|
+
|
|
135
|
+
const dir = join(root, PACK_DIR, name);
|
|
136
|
+
const source = typeof entry.source === 'string' ? entry.source : null;
|
|
137
|
+
const version = typeof entry.version === 'string' ? entry.version : null;
|
|
138
|
+
let present = false;
|
|
139
|
+
try {
|
|
140
|
+
present = (await stat(dir)).isDirectory();
|
|
141
|
+
} catch {
|
|
142
|
+
present = false;
|
|
143
|
+
}
|
|
144
|
+
if (!present) {
|
|
145
|
+
findings.push({
|
|
146
|
+
file: `${PACK_DIR}/${name}`,
|
|
147
|
+
status: 'pack-missing',
|
|
148
|
+
detail: `the manifest declares pack \`${name}\`, and there is no directory for it; every rule it carries is missing from this repository's projection`,
|
|
149
|
+
remedy: `restore it with: git checkout -- ${PACK_DIR}/${name} (or remove the declaration, then: ${CLI} sync --yes)`,
|
|
150
|
+
});
|
|
151
|
+
packs.push({ name, dir, source, version, digest: '', declared: '', present: false, ok: false });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const digest = await packDigest(dir);
|
|
156
|
+
const declaredDigest = typeof entry.digest === 'string' ? entry.digest : null;
|
|
157
|
+
// An absent digest is not a mismatch. A pack someone vendored and has not
|
|
158
|
+
// pinned yet still carries its rules, and refusing to project them would make
|
|
159
|
+
// the tool refuse a repository for a field it has always treated as optional.
|
|
160
|
+
// It is reported, because "pinned to nothing" is not a fact about the content.
|
|
161
|
+
let ok = true;
|
|
162
|
+
if (declaredDigest === null) {
|
|
163
|
+
findings.push({
|
|
164
|
+
file: MANIFEST_FILE,
|
|
165
|
+
status: 'pack-unpinned',
|
|
166
|
+
detail: `pack \`${name}\` has no digest, so nothing records which bytes it was declared at`,
|
|
167
|
+
remedy: `record \`${digest}\` as its digest, then: ${CLI} sync --yes`,
|
|
168
|
+
});
|
|
169
|
+
} else if (declaredDigest !== digest) {
|
|
170
|
+
ok = false;
|
|
171
|
+
findings.push({
|
|
172
|
+
file: `${PACK_DIR}/${name}`,
|
|
173
|
+
status: 'pack-digest-mismatch',
|
|
174
|
+
detail: `pack \`${name}\` is pinned to ${declaredDigest} and its bytes digest to ${digest}`,
|
|
175
|
+
remedy: `restore the pinned bytes with: git checkout -- ${PACK_DIR}/${name} (or update the digest to ${digest}, then: ${CLI} sync --yes)`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
packs.push({ name, dir, source, version, digest, declared: declaredDigest ?? digest, present: true, ok });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { packs, findings };
|
|
182
|
+
}
|