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.
Files changed (90) hide show
  1. package/CHANGELOG.md +375 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  10. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  11. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  12. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  13. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  14. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  15. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  16. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  17. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  18. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  19. package/docs/decisions/README.md +17 -0
  20. package/package.json +1 -2
  21. package/rules/core.md +1 -0
  22. package/rules/stacks/android.md +2 -0
  23. package/rules/stacks/cloudflare-workers.md +2 -0
  24. package/rules/stacks/connectiq.md +1 -0
  25. package/rules/stacks/container.md +2 -0
  26. package/rules/stacks/dart.md +40 -0
  27. package/rules/stacks/deno.md +38 -0
  28. package/rules/stacks/dotnet.md +37 -0
  29. package/rules/stacks/elixir.md +41 -0
  30. package/rules/stacks/go.md +36 -0
  31. package/rules/stacks/jvm.md +2 -0
  32. package/rules/stacks/node.md +2 -0
  33. package/rules/stacks/python.md +2 -0
  34. package/rules/stacks/react-native.md +2 -0
  35. package/rules/stacks/react-web.md +2 -0
  36. package/rules/stacks/ruby.md +38 -0
  37. package/rules/stacks/rust.md +36 -0
  38. package/rules/stacks/swift.md +39 -0
  39. package/rules/stacks/typescript.md +2 -0
  40. package/rules/stacks/uv.md +2 -0
  41. package/src/argv.mjs +54 -6
  42. package/src/ci/dependabot.mjs +1 -1
  43. package/src/ci/workflow.mjs +33 -11
  44. package/src/commands/compare.mjs +15 -0
  45. package/src/commands/graph.mjs +58 -25
  46. package/src/commands/harness.mjs +150 -0
  47. package/src/commands/help.mjs +37 -5
  48. package/src/commands/hooks.mjs +173 -57
  49. package/src/commands/index.mjs +95 -69
  50. package/src/commands/init.mjs +100 -20
  51. package/src/commands/mcp.mjs +20 -5
  52. package/src/commands/rules.mjs +96 -60
  53. package/src/commands/skills.mjs +189 -86
  54. package/src/commands/skillsinstall.mjs +20 -5
  55. package/src/commands/sync.mjs +213 -15
  56. package/src/commands/tokens.mjs +138 -0
  57. package/src/compare/hooksstate.mjs +96 -9
  58. package/src/compare/row.mjs +107 -7
  59. package/src/decisions/index.mjs +21 -6
  60. package/src/graph/load.mjs +125 -14
  61. package/src/graph/spec.mjs +227 -0
  62. package/src/graph/speccheck.mjs +68 -0
  63. package/src/hooks/capability.mjs +48 -4
  64. package/src/hooks/dispatcher.mjs +84 -7
  65. package/src/hooks/gates.mjs +0 -2
  66. package/src/hooks/resolve.mjs +24 -25
  67. package/src/hooks/shim.mjs +29 -5
  68. package/src/identity.mjs +0 -11
  69. package/src/mcp/schema.mjs +139 -0
  70. package/src/mcp/server.mjs +197 -20
  71. package/src/mcp/tools.mjs +290 -3
  72. package/src/measure/context.mjs +371 -0
  73. package/src/monorepo.mjs +339 -0
  74. package/src/output/index.mjs +86 -9
  75. package/src/package-version.mjs +74 -0
  76. package/src/packs.mjs +182 -0
  77. package/src/projection/conflicts.mjs +56 -5
  78. package/src/projection/manifest.mjs +335 -25
  79. package/src/projection/mdc.mjs +4 -13
  80. package/src/projection/project.mjs +139 -8
  81. package/src/projection/rules.mjs +308 -27
  82. package/src/projection/surfaces.mjs +133 -15
  83. package/src/projection/targets.mjs +90 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +1 -0
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +1 -1
  89. package/src/workspace.mjs +0 -9
  90. package/graph/skills.graph.json +0 -1622
@@ -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
+ }
@@ -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-dev.json missing or invalid
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
- [EXIT.USAGE]: 'run: simt-dev --help',
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] ?? 'run: simt-dev --help';
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
- export function emitJson(value, { stream = process.stdout } = {}) {
87
- stream.write(`${JSON.stringify(value, null, 2)}\n`);
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
+ }