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
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
|
@@ -12,17 +12,40 @@ export const EXIT = {
|
|
|
12
12
|
OK: 0,
|
|
13
13
|
FAIL: 1, // a gate failed: drift, policy violation, missing evidence
|
|
14
14
|
USAGE: 2, // bad arguments; the remedy is in the message
|
|
15
|
-
CONFIG: 3, // simt
|
|
15
|
+
CONFIG: 3, // simt.json missing or invalid
|
|
16
16
|
REGISTRY: 4, // skills.graph.json invalid: orphan, dangling edge, cycle
|
|
17
17
|
UNSUPPORTED: 5, // the request cannot be satisfied on this platform or repo
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
const REMEDY = {
|
|
21
|
-
|
|
21
|
+
// Every default remedy below is `${CLI}` and not the package name, because this
|
|
22
|
+
// package installs exactly one binary and `package.json`'s `bin` map says which:
|
|
23
|
+
// `simt`. A remedy naming `simt-dev` is a command the reader types and gets
|
|
24
|
+
// `unknown command` from, which is the one answer that leaves them with no next
|
|
25
|
+
// step at all - and it was this default, printed on every bad invocation.
|
|
26
|
+
[EXIT.USAGE]: `run: ${CLI} --help`,
|
|
22
27
|
[EXIT.CONFIG]: `run: ${CLI} init`,
|
|
23
28
|
[EXIT.REGISTRY]: `run: ${CLI} graph lint`,
|
|
24
29
|
};
|
|
25
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
|
+
|
|
26
49
|
/**
|
|
27
50
|
* Report a failure and return its exit code. Callers `return fail(...)` so the
|
|
28
51
|
* code is impossible to forget.
|
|
@@ -32,7 +55,11 @@ export function fail(message, code = EXIT.FAIL, remedy) {
|
|
|
32
55
|
// default and it was only ever resolvable from inside simt-dev's own checkout;
|
|
33
56
|
// a reader in a consumer repo got a remedy naming a file that does not exist
|
|
34
57
|
// where they are. Every remedy here is something the reader can run.
|
|
35
|
-
const resolved = remedy ?? REMEDY[code] ??
|
|
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
|
+
}
|
|
36
63
|
process.stderr.write(`ERROR ${code} ${message}\nREMEDY ${resolved}\n`);
|
|
37
64
|
return code;
|
|
38
65
|
}
|
|
@@ -60,10 +87,6 @@ export function writeMode(flags) {
|
|
|
60
87
|
return { dryRun, apply: flags.yes === true && !dryRun };
|
|
61
88
|
}
|
|
62
89
|
|
|
63
|
-
export function isTty() {
|
|
64
|
-
return process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
90
|
/**
|
|
68
91
|
* Emit a block of text under a heading, respecting --max-bytes.
|
|
69
92
|
* Truncation is always announced with an explicit line, never a silent ellipsis:
|
|
@@ -83,8 +106,62 @@ export function emit(text, { maxBytes = 0, stream = process.stdout } = {}) {
|
|
|
83
106
|
return true;
|
|
84
107
|
}
|
|
85
108
|
|
|
86
|
-
|
|
87
|
-
|
|
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;
|
|
88
165
|
}
|
|
89
166
|
|
|
90
167
|
/**
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// How simt-dev is installed, and the version a repository holds it to.
|
|
2
|
+
//
|
|
3
|
+
// Three distinct things that must not be confused:
|
|
4
|
+
// - packageVersion() is the version of the binary that is RUNNING. It is read from
|
|
5
|
+
// the package this code lives in, so it is correct whether the binary was resolved
|
|
6
|
+
// from node_modules, a global install, or a source checkout.
|
|
7
|
+
// - hasNpm(root) is the KIND of repository: does it have a package.json? That decides
|
|
8
|
+
// how it obtains simt-dev at all — pinned as a devDependency where one exists, a
|
|
9
|
+
// pinned global install where there is no node_modules to pin in.
|
|
10
|
+
// - the pin is the version a repository recorded when it adopted simt-dev, stored in
|
|
11
|
+
// the manifest's projection.version. It is what a global install must match: npm
|
|
12
|
+
// pins the node_modules copy for us, but nothing pins a global binary, so the gate
|
|
13
|
+
// compares the running version against the pin and refuses on a mismatch.
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { stat } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { MANIFEST_FILE } from './name.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The version of the simt-dev package this code is running from.
|
|
23
|
+
*
|
|
24
|
+
* Sync, because the render functions it feeds are sync: a rendered artifact bakes in
|
|
25
|
+
* the version that produced it, and that must be known before the bytes are written.
|
|
26
|
+
* A missing or unreadable package.json degrades to 'unknown' rather than throwing —
|
|
27
|
+
* the same call already tolerates it in `--version`, and a render that cannot read its
|
|
28
|
+
* own version is not a reason to crash the command that calls it.
|
|
29
|
+
*/
|
|
30
|
+
export function packageVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
33
|
+
return pkg.version;
|
|
34
|
+
} catch {
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a repository has a package.json, i.e. whether it pins simt-dev as a
|
|
41
|
+
* devDependency (npm) or must fall back to a global install. Async because the render
|
|
42
|
+
* call sites that feed it (init, hooks install, the compare stale check) are all async,
|
|
43
|
+
* and a missing file is an answer (false), not an error.
|
|
44
|
+
*/
|
|
45
|
+
export async function hasNpm(root) {
|
|
46
|
+
try {
|
|
47
|
+
await stat(join(root, 'package.json'));
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether the running binary matches the version a repository pinned when it adopted
|
|
56
|
+
* simt-dev. Returns null when there is nothing to refuse, otherwise a message that
|
|
57
|
+
* names both versions and the one command that fixes it.
|
|
58
|
+
*
|
|
59
|
+
* This is the gate's fail-closed guard against a stale global binary: for a repository
|
|
60
|
+
* without npm, simt-dev is a global install and the only thing that knows which version
|
|
61
|
+
* it should be is the pin. A node_modules copy is pinned by npm itself, so its mismatch
|
|
62
|
+
* is a stale manifest, which `upgrade` refreshes — the remedy is the same either way.
|
|
63
|
+
*
|
|
64
|
+
* A manifest with no recorded pin (adopted before the field existed) is skipped, not
|
|
65
|
+
* failed: there is nothing to compare against, and failing it would turn every pre-0.2
|
|
66
|
+
* repository into a red gate the moment this check ships.
|
|
67
|
+
*/
|
|
68
|
+
export function assertVersionPin(manifest) {
|
|
69
|
+
const pin = manifest?.projection?.version;
|
|
70
|
+
if (!pin || pin === 'unknown') return null;
|
|
71
|
+
const running = packageVersion();
|
|
72
|
+
if (running === pin) return null;
|
|
73
|
+
return `simt-dev version mismatch: ${MANIFEST_FILE} pins ${pin}, the running binary is ${running}`;
|
|
74
|
+
}
|