simt-dev 0.2.0 → 0.2.1

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 (56) hide show
  1. package/CHANGELOG.md +173 -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/README.md +17 -0
  10. package/package.json +1 -2
  11. package/rules/stacks/dart.md +38 -0
  12. package/rules/stacks/deno.md +36 -0
  13. package/rules/stacks/dotnet.md +35 -0
  14. package/rules/stacks/elixir.md +39 -0
  15. package/rules/stacks/go.md +34 -0
  16. package/rules/stacks/ruby.md +36 -0
  17. package/rules/stacks/rust.md +34 -0
  18. package/rules/stacks/swift.md +37 -0
  19. package/src/argv.mjs +35 -6
  20. package/src/ci/dependabot.mjs +1 -1
  21. package/src/ci/workflow.mjs +33 -11
  22. package/src/commands/graph.mjs +13 -4
  23. package/src/commands/help.mjs +22 -4
  24. package/src/commands/hooks.mjs +173 -57
  25. package/src/commands/index.mjs +53 -45
  26. package/src/commands/init.mjs +89 -13
  27. package/src/commands/rules.mjs +54 -3
  28. package/src/commands/skills.mjs +143 -15
  29. package/src/commands/skillsinstall.mjs +3 -3
  30. package/src/commands/sync.mjs +153 -11
  31. package/src/compare/hooksstate.mjs +96 -9
  32. package/src/compare/row.mjs +34 -5
  33. package/src/decisions/index.mjs +21 -6
  34. package/src/graph/load.mjs +125 -14
  35. package/src/hooks/capability.mjs +48 -4
  36. package/src/hooks/dispatcher.mjs +50 -5
  37. package/src/hooks/gates.mjs +0 -2
  38. package/src/hooks/resolve.mjs +24 -25
  39. package/src/hooks/shim.mjs +29 -5
  40. package/src/identity.mjs +0 -11
  41. package/src/mcp/tools.mjs +2 -2
  42. package/src/output/index.mjs +8 -7
  43. package/src/package-version.mjs +74 -0
  44. package/src/projection/conflicts.mjs +56 -5
  45. package/src/projection/manifest.mjs +247 -25
  46. package/src/projection/mdc.mjs +4 -13
  47. package/src/projection/project.mjs +1 -1
  48. package/src/projection/rules.mjs +21 -8
  49. package/src/projection/targets.mjs +53 -7
  50. package/src/resolve/eval.mjs +64 -0
  51. package/src/resolve/fingerprint.mjs +1 -0
  52. package/src/resolve/near.mjs +26 -7
  53. package/src/resolve/resolve.mjs +1 -1
  54. package/src/resolve/source.mjs +1 -1
  55. package/src/workspace.mjs +0 -9
  56. package/graph/skills.graph.json +0 -1622
@@ -15,15 +15,95 @@
15
15
 
16
16
  import { readdir, readFile, stat } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
- import { dirname, join, resolve } from 'node:path';
19
- import { fileURLToPath } from 'node:url';
18
+ import { join } from 'node:path';
20
19
 
21
20
  import { KNOWN_TAGS } from '../resolve/fingerprint.mjs';
22
21
  import { matchesGlob, syntheticPathFor, tokenize } from '../resolve/resolve.mjs';
23
- import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
22
+ import { PACKAGE_ROOT, resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
24
23
 
25
- const HERE = dirname(fileURLToPath(import.meta.url));
26
- export const REGISTRY_PATH = resolve(HERE, '..', '..', 'graph', 'skills.graph.json');
24
+ /**
25
+ * The registry is workspace data (ADR-061): it describes which skill bodies a particular
26
+ * workspace holds, where they are, how big they are, and which tags separate the variants.
27
+ * It therefore lives in a MEMBER of the workspace, not in the workspace root - the root is
28
+ * the directory that CONTAINS the member repositories, and it holds no `graph/` of its own.
29
+ * An explicitly named path always wins over everything below.
30
+ */
31
+ function defaultRegistryPath() {
32
+ return join(PACKAGE_ROOT, 'graph', 'skills.graph.json');
33
+ }
34
+
35
+ /**
36
+ * The registry to read when the reader names none, in order (ADR-061 ruling 3):
37
+ *
38
+ * 1. The package root, when it holds one. In this checkout that is how the tool finds its
39
+ * own registry; in an installed package `graph` is not in the published `files`, so it
40
+ * never does, and the reader falls through to 2 instead of being handed this workspace's
41
+ * catalogue.
42
+ * 2. A member of the resolved workspace, when exactly one holds one: a tool installed from
43
+ * node_modules or a formula resolves its workspace by configuration, and the member that
44
+ * holds the registry is usually the tool's own checkout. More than one holding it is
45
+ * ambiguity and the reader must pick; none is an honest absence, reported with the
46
+ * workspace that was looked in.
47
+ *
48
+ * Every step is file existence plus the configured workspace, so the answer can be
49
+ * explained to whoever asks (determinism). `errors` empty means a path was found.
50
+ *
51
+ * @returns {Promise<{where: string|null, errors: string[], workspace: {root: string|null, source: string, detail: string}|null}>}
52
+ */
53
+ async function defaultRegistry(flags) {
54
+ const own = defaultRegistryPath();
55
+ if (existsSync(own)) return { where: own, errors: [], workspace: null };
56
+
57
+ const ws = await workspace(flags);
58
+ const info = { root: ws.root, source: ws.source, detail: ws.detail };
59
+ const absent = `no skill registry at ${own}; create one there, or name it explicitly`;
60
+ if (ws.error) {
61
+ return { where: own, errors: [`${ws.detail}: ${ws.error}`, absent], workspace: info };
62
+ }
63
+ if (ws.root === null) {
64
+ return { where: own, errors: [absent], workspace: info };
65
+ }
66
+ if (!existsSync(ws.root)) {
67
+ return {
68
+ where: own,
69
+ errors: [`no skill registry: the configured workspace ${ws.root} (${ws.detail}) does not exist`],
70
+ workspace: info,
71
+ };
72
+ }
73
+
74
+ const members = [];
75
+ let entries;
76
+ try {
77
+ entries = await readdir(ws.root, { withFileTypes: true });
78
+ } catch {
79
+ entries = null;
80
+ }
81
+ for (const entry of entries ?? []) {
82
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
83
+ if (existsSync(join(ws.root, entry.name, 'graph', 'skills.graph.json'))) members.push(entry.name);
84
+ }
85
+
86
+ if (members.length === 1) {
87
+ return { where: join(ws.root, members[0], 'graph', 'skills.graph.json'), errors: [], workspace: info };
88
+ }
89
+ if (members.length > 1) {
90
+ return {
91
+ where: null,
92
+ errors: [
93
+ `ambiguous registry: ${members.length} members of workspace ${ws.root} each hold ` +
94
+ `graph/skills.graph.json (${members.join(', ')}); name one explicitly`,
95
+ ],
96
+ workspace: info,
97
+ };
98
+ }
99
+ return {
100
+ where: own,
101
+ errors: [
102
+ `no skill registry in workspace ${ws.root} (${ws.detail}); create one in a member, or name it explicitly`,
103
+ ],
104
+ workspace: info,
105
+ };
106
+ }
27
107
 
28
108
  /**
29
109
  * Where sibling repos live. Sources are declared as `repo/path`, so resolving one
@@ -44,11 +124,6 @@ export function workspace(flags = {}) {
44
124
  return workspacePromise;
45
125
  }
46
126
 
47
- /** Test seam: forget the memoised workspace so the next call re-resolves. */
48
- export function resetWorkspace() {
49
- workspacePromise = null;
50
- }
51
-
52
127
  const VALID_ROLES = new Set(['canonical', 'variant', 'stub', 'duplicate', 'deprecated']);
53
128
 
54
129
  /**
@@ -63,15 +138,47 @@ const VALID_ROLES = new Set(['canonical', 'variant', 'stub', 'duplicate', 'depre
63
138
  * @returns {Promise<{ok: boolean, registry: object|null, errors: string[], warnings: string[],
64
139
  * verified: boolean, workspace: {root: string|null, source: string, detail: string}>}
65
140
  */
66
- export async function loadRegistry(path = REGISTRY_PATH, flags = {}) {
141
+ export async function loadRegistry(path = null, flags = {}) {
142
+ // An explicitly named path wins over the default (ADR-061). When none is given,
143
+ // `defaultRegistry` looks in the package root and then in the workspace's members; its
144
+ // errors are facts about ABSENCE - no registry here, or more than one and the reader must
145
+ // pick - and are reported as such. A named path that is missing is a different fact: the
146
+ // reader pointed at a file that is not there, and it names that path.
147
+ let where = path;
148
+ let resolveErrors = null;
149
+ let resolvedWorkspace = null;
150
+ if (path === null) {
151
+ const found = await defaultRegistry(flags);
152
+ where = found.where;
153
+ resolveErrors = found.errors;
154
+ resolvedWorkspace = found.workspace;
155
+ }
156
+
157
+ if (resolveErrors !== null && resolveErrors.length > 0) {
158
+ return {
159
+ ok: false,
160
+ registry: null,
161
+ path: where,
162
+ errors: resolveErrors,
163
+ warnings: [],
164
+ verified: false,
165
+ workspace: resolvedWorkspace ?? { root: null, source: 'none', detail: 'not resolved' },
166
+ };
167
+ }
168
+
67
169
  let registry;
68
170
  try {
69
- registry = JSON.parse(await readFile(path, 'utf8'));
171
+ registry = JSON.parse(await readFile(where, 'utf8'));
70
172
  } catch (error) {
173
+ const message =
174
+ path === null && error.code === 'ENOENT'
175
+ ? `no skill registry at ${where}; create one there, or name it explicitly`
176
+ : `${where}: unreadable or invalid JSON (${error.message})`;
71
177
  return {
72
178
  ok: false,
73
179
  registry: null,
74
- errors: [`${path}: unreadable or invalid JSON (${error.message})`],
180
+ path: where,
181
+ errors: [message],
75
182
  warnings: [],
76
183
  verified: false,
77
184
  workspace: { root: null, source: 'none', detail: 'not resolved' },
@@ -127,6 +234,7 @@ export async function loadRegistry(path = REGISTRY_PATH, flags = {}) {
127
234
  return {
128
235
  ok: errors.length === 0,
129
236
  registry,
237
+ path: where,
130
238
  errors,
131
239
  warnings,
132
240
  verified,
@@ -465,8 +573,11 @@ async function lintSourcesOnDisk(registry, workspaceRoot) {
465
573
  continue;
466
574
  }
467
575
  if (typeof source.bytes === 'number' && source.bytes !== measured.bytes) {
576
+ // The delta is computed here, not in `measureSkill`: that function measures
577
+ // one path and knows nothing about the declaration it is being compared with.
578
+ const delta = measured.bytes - source.bytes;
468
579
  errors.push(
469
- `${at}: declared ${source.bytes} bytes, on disk ${measured.bytes} (${measured.delta > 0 ? '+' : ''}${measured.delta})`,
580
+ `${at}: declared ${source.bytes} bytes, on disk ${measured.bytes} (${delta > 0 ? '+' : ''}${delta})`,
470
581
  );
471
582
  }
472
583
  if (!source.bytes) warnings.push(`${at}: no declared bytes to compare against`);
@@ -18,9 +18,10 @@
18
18
  // cannot run a `lint` gate, and this reads the manifest rather than the dependency graph
19
19
  // so the two can never disagree about what a script does.
20
20
 
21
- import { readdir, readFile, stat } from 'node:fs/promises';
21
+ import { constants as fsConstants } from 'node:fs';
22
+ import { access, readdir, readFile, stat } from 'node:fs/promises';
22
23
  import { join } from 'node:path';
23
- import { manifestPath } from '../projection/manifest.mjs';
24
+ import { ECOSYSTEMS, manifestPath } from '../projection/manifest.mjs';
24
25
 
25
26
  /** Binaries the shared dispatcher shells out to directly, rather than via a script. */
26
27
  const BIN_DIR = join('node_modules', '.bin');
@@ -59,14 +60,49 @@ async function fileExists(path) {
59
60
  }
60
61
  }
61
62
 
63
+ /**
64
+ * Does an EXECUTABLE file exist at this path?
65
+ *
66
+ * A wrapper that exists but is not executable is a probe failure, not a success:
67
+ * declaring it would plant a command that fails closed on every commit — the defect
68
+ * ADR-017 exists to prevent, reached from the ecosystem table rather than the stack.
69
+ */
70
+ async function executableFile(path) {
71
+ try {
72
+ if (!(await stat(path)).isFile()) return false;
73
+ await access(path, fsConstants.X_OK);
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Can this machine run the ecosystem's program?
82
+ *
83
+ * Two kinds of evidence, matching the two kinds of tool in `ECOSYSTEMS`: a binary on
84
+ * PATH, probed directory by directory the way the shell would find it, and a file the
85
+ * repository commits (the Maven wrapper), probed in place the way `gradlew` is probed.
86
+ * Either answer that is not a positive YES is NO: a seed that guesses a toolchain is
87
+ * a declared gate that cannot run, and the dispatcher fails closed on it.
88
+ */
89
+ async function probeTool(root, eco) {
90
+ if (eco.file) return executableFile(join(root, eco.tool));
91
+ const dirs = (process.env.PATH ?? '').split(':').filter(Boolean);
92
+ for (const dir of dirs) {
93
+ if (await executableFile(join(dir, eco.tool))) return true;
94
+ }
95
+ return false;
96
+ }
97
+
62
98
  /**
63
99
  * Read what this repo can run.
64
100
  *
65
101
  * @param {string} root
66
- * @returns {Promise<{scripts: Record<string,string>, binaries: string[], commands: Record<string,string>, runner: string, error: string|null}>}
102
+ * @returns {Promise<{scripts: Record<string,string>, binaries: string[], commands: Record<string,string>, tools: string[], runner: string, error: string|null}>}
67
103
  */
68
104
  export async function readCapability(root) {
69
- const capability = { scripts: {}, binaries: [], commands: {}, runner: null, error: null };
105
+ const capability = { scripts: {}, binaries: [], commands: {}, tools: [], runner: null, error: null };
70
106
 
71
107
  try {
72
108
  const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
@@ -104,6 +140,14 @@ export async function readCapability(root) {
104
140
  // No node_modules is the same as no installed binaries.
105
141
  }
106
142
 
143
+ // `tools` answers "can this MACHINE run the ecosystem's program", which the marker
144
+ // file in the repo cannot: a Cargo.toml without cargo installed is an ecosystem that
145
+ // is here but whose gates would all fail closed. The probe is per-row, because a row
146
+ // that marks its tool `file` lives in the repository, not on PATH.
147
+ for (const eco of ECOSYSTEMS) {
148
+ if (await probeTool(root, eco)) capability.tools.push(eco.tool);
149
+ }
150
+
107
151
  // `gates.commands` lives in simt-dev.json, NOT package.json, so it has to be read
108
152
  // here or a command gate looks undeclared to `check` while the dispatcher runs it
109
153
  // successfully — the gate and the checker disagreeing about the same manifest.
@@ -27,11 +27,12 @@ import { GATES } from './gates.mjs';
27
27
  import { resolveGate } from './resolve.mjs';
28
28
  import { CLI } from '../name.mjs';
29
29
  import { MANIFEST_FILE } from '../name.mjs';
30
+ import { assertVersionPin } from '../package-version.mjs';
30
31
 
31
32
  const exec = promisify(execFile);
32
33
 
33
34
  /** Gates that are known but not required for the event to pass. */
34
- export const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
35
+ const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
35
36
 
36
37
  /**
37
38
  * @param {string} event 'pre-commit' | 'pre-push'
@@ -65,6 +66,21 @@ export async function runGates(event, { root, manifest }) {
65
66
  };
66
67
  }
67
68
 
69
+ // The gates below run from the binary that resolved this shim. For a repository
70
+ // without npm that is a global install, whose version nothing else pins: if it is not
71
+ // the one this repo adopted, the gates that run here are not the ones CI runs.
72
+ // Placed after the empty-list check on purpose - with no gates there is nothing the
73
+ // version could get wrong, and failing that case would block commits in repos that
74
+ // deliberately declare none.
75
+ const mismatch = assertVersionPin(manifest);
76
+ if (mismatch) {
77
+ return {
78
+ event,
79
+ gates: [{ name: 'version', status: 'failed', detail: mismatch, remedy: `run: ${CLI} upgrade --yes`, adr: null }],
80
+ failed: true,
81
+ };
82
+ }
83
+
68
84
  const pkg = await readPkg(root);
69
85
  const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
70
86
  const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
@@ -131,7 +147,7 @@ export async function runGates(event, { root, manifest }) {
131
147
  formatted = targets;
132
148
  }
133
149
 
134
- const outcome = await run(resolution.argv, root);
150
+ const outcome = await runGate(resolution, root);
135
151
  results.push({
136
152
  name,
137
153
  status: outcome.code === 0 ? 'passed' : 'failed',
@@ -186,6 +202,37 @@ async function run(argv, cwd) {
186
202
  }
187
203
  }
188
204
 
205
+ /**
206
+ * Run a gate's resolution, as a shell line or as an argv.
207
+ *
208
+ * A declared command is a line the repository wrote in its own manifest and would type
209
+ * into its own shell (ADR-019 ruling 8), so it goes through `sh -c` and everything it
210
+ * wrote survives — quotes, pipes, `&&`, a flag with a space in its value. Everything
211
+ * this tool resolved itself stays an argv, because that is a path this tool chose out of
212
+ * `node_modules/.bin` and handing it to a shell would be a change of trust for no gain.
213
+ *
214
+ * `sh -c` is not a sandbox and is not meant to be. The trust boundary is the repository:
215
+ * the line came from its own manifest, which it can read and change, and it runs on the
216
+ * author's own machine at the author's own request. The only property worth defending is
217
+ * that the two sources stay distinguishable, which is why this is a separate branch
218
+ * rather than a flag on `run`.
219
+ */
220
+ async function runGate(resolution, cwd) {
221
+ if (resolution.shell === true && typeof resolution.command === 'string') {
222
+ try {
223
+ const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, maxBuffer: 8 * 1024 * 1024 });
224
+ return { code: 0, stdout, stderr };
225
+ } catch (error) {
226
+ return {
227
+ code: typeof error.code === 'number' ? error.code : 1,
228
+ stdout: error.stdout ?? '',
229
+ stderr: error.stderr ?? String(error.message ?? error),
230
+ };
231
+ }
232
+ }
233
+ return run(resolution.argv, cwd);
234
+ }
235
+
189
236
  async function readPkg(root) {
190
237
  try {
191
238
  return JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
@@ -212,7 +259,7 @@ export async function stagedFiles(root) {
212
259
  * A path staged as DELETED is not here: the index and the working tree agree that
213
260
  * it is gone, so there is no unstaged work to protect and no reason to refuse.
214
261
  */
215
- export async function unstagedFiles(root) {
262
+ async function unstagedFiles(root) {
216
263
  try {
217
264
  const { stdout } = await exec('git', ['diff', '--name-only'], { cwd: root });
218
265
  return new Set(stdout.split('\n').map((s) => s.trim()).filter(Boolean));
@@ -264,5 +311,3 @@ function tail(text, lines = 12) {
264
311
  const parts = String(text).trimEnd().split('\n');
265
312
  return parts.slice(-lines).join('\n');
266
313
  }
267
-
268
- export const __internals = { tail, basename };
@@ -140,5 +140,3 @@ export function unresolved(name, { hasScript, binary, command, stack }) {
140
140
  export function gateNames() {
141
141
  return Object.keys(GATES).sort();
142
142
  }
143
-
144
- export const __internals = { FORMATTABLE, GATES };
@@ -14,7 +14,7 @@ import { access } from 'node:fs/promises';
14
14
  import { constants } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
 
17
- import { FORMATTABLE, GATES, GATES as _G } from './gates.mjs';
17
+ import { FORMATTABLE, GATES } from './gates.mjs';
18
18
  import { MANIFEST_FILE } from '../name.mjs';
19
19
 
20
20
  /** Gate names the shared dispatcher knows, sorted. */
@@ -22,23 +22,8 @@ export function knownGates() {
22
22
  return Object.keys(GATES).sort();
23
23
  }
24
24
 
25
- export function gateKind(name) {
26
- return GATES[name]?.kind ?? null;
27
- }
28
-
29
- export function gateWhy(name) {
30
- return GATES[name]?.why ?? null;
31
- }
32
-
33
- export function gateAdr(name) {
34
- return GATES[name]?.adr ?? null;
35
- }
36
-
37
- /**
38
- * Is this path an executable file?
39
- * @param {string} path
40
- */
41
- export async function isExecutable(path) {
25
+ /** Is this path an executable file? */
26
+ async function isExecutable(path) {
42
27
  try {
43
28
  await access(path, constants.X_OK);
44
29
  return true;
@@ -96,8 +81,20 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
96
81
  // own remedy, to "name the program under gates.commands"; a dispatcher that then
97
82
  // ignored that declaration would contradict the remedy, and so would `check`.
98
83
  if (declared) {
99
- const argv = declared.trim().split(/\s+/).filter(Boolean);
100
- if (argv.length === 0) {
84
+ // The declaration is a SHELL COMMAND LINE, not an argv (ADR-019, ruling 8).
85
+ //
86
+ // It was split on whitespace, so every declaration a repository would plausibly
87
+ // write was wrong: `npm run test -- --watch=false` became seven arguments with the
88
+ // flag value as a separate one, and `sh -c "a && b"` became eleven, with the quotes
89
+ // passed to the program. A repository had no way to write a gate with a quoted
90
+ // argument, and nothing said so — the gate simply ran the wrong thing.
91
+ //
92
+ // Splitting is also a parse without an owner. `execFile` takes an argv because it
93
+ // must not use a shell, which is right for a path this tool chose and wrong for a
94
+ // line the repository wrote in full view of its own shell. The two cases are now
95
+ // different types rather than one `argv` field carrying both.
96
+ const line = declared.trim();
97
+ if (line === '') {
101
98
  return {
102
99
  ok: false,
103
100
  gate: name,
@@ -109,12 +106,16 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
109
106
  return {
110
107
  ok: true,
111
108
  kind: gate.kind,
112
- argv,
109
+ // Kept alongside `command` because the dispatcher prints and passes it to
110
+ // `hooks status`, and two representations of one thing is how they drift.
111
+ argv: line.split(/\s+/).filter(Boolean),
112
+ command: line,
113
+ shell: true,
113
114
  // The dispatcher has to know whether these argv entries are paths it chose or
114
115
  // words it was handed. Sniffing the detail string meant the safety guard on
115
116
  // partial stages would quietly stop working the day that wording changed.
116
117
  declared: true,
117
- detail: `declared command: ${declared}`,
118
+ detail: `declared command: ${line}`,
118
119
  };
119
120
  }
120
121
 
@@ -189,6 +190,4 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
189
190
  function extname(path) {
190
191
  const dot = path.lastIndexOf('.');
191
192
  return dot > 0 ? path.slice(dot).toLowerCase() : '';
192
- }
193
-
194
- export const __internals = { extname, FORMATTABLE };
193
+ }
@@ -17,7 +17,20 @@
17
17
  // start in CI. See src/locate.mjs.
18
18
 
19
19
  import { renderLocator } from '../locate.mjs';
20
- import { BIN_ENV, CLI, MANIFEST_FILE } from '../name.mjs';
20
+ import { BIN_ENV, CLI, MANIFEST_FILE, PACKAGE_NAME } from '../name.mjs';
21
+ import { packageVersion } from '../package-version.mjs';
22
+
23
+ /**
24
+ * A line only this tool's dispatcher shim carries.
25
+ *
26
+ * The banner, not the path and not the name. `pre-commit` is what a hand-written gate
27
+ * is called too, and the file can sit wherever git looks, so neither of those can say
28
+ * who wrote it. Exported rather than matched as a literal somewhere else because the
29
+ * question this answers is asked from both sides: this file writes it, and
30
+ * `readHooksState` reads it back to decide whether a hook it cannot recognise as
31
+ * current is nonetheless its own.
32
+ */
33
+ export const SHIM_MARKER = '# Generated by simt-dev. Do not edit.';
21
34
 
22
35
  /** Events the shared dispatcher knows how to run. */
23
36
  export const EVENTS = ['pre-commit', 'pre-push'];
@@ -34,15 +47,26 @@ export function fileFor(event) {
34
47
 
35
48
  /**
36
49
  * @param {string} event one of EVENTS
50
+ * @param {{npm?: boolean, version?: string}} [opts]
51
+ * `npm` is whether the repository has a package.json: it decides the remedy's install
52
+ * (a devDependency where one exists, a pinned global install where there is no
53
+ * node_modules to pin in). `version` defaults to the running package's own version,
54
+ * so a shim generated by 0.2.0 names 0.2.0 in its remedy and CI install.
37
55
  * @returns {string} POSIX sh source, LF-terminated, executable
38
56
  */
39
- export function renderShim(event) {
57
+ export function renderShim(event, { npm = true, version } = {}) {
40
58
  if (!EVENTS.includes(event)) {
41
59
  throw new Error(`unknown hook event ${event}; known: ${EVENTS.join(', ')}`);
42
60
  }
61
+ const v = version ?? packageVersion();
62
+ // The install that finds the binary when node_modules and PATH both come up empty.
63
+ // A repository with a package.json pins simt-dev as a devDependency, so the remedy
64
+ // installs it there; one without (Gradle, Connect IQ) has no node_modules to pin in,
65
+ // so the remedy is a pinned global install - the same pinned bytes CI gets.
66
+ const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : `npm i -g ${PACKAGE_NAME}@${v}`;
43
67
  const D = '$';
44
68
  return `#!/bin/sh
45
- # Generated by simt-dev. Do not edit.
69
+ ${SHIM_MARKER}
46
70
  #
47
71
  # Every decision this hook makes is declared in ${MANIFEST_FILE}, so a reviewer reads
48
72
  # the gate list in a diff instead of reverse-engineering shell. Reinstall with:
@@ -51,7 +75,7 @@ export function renderShim(event) {
51
75
  # This file only locates the dispatcher and hands over. It resolves in order:
52
76
  # 1. ${D}SIMT_DEV_BIN explicit override, for CI images and odd checkouts
53
77
  # 2. node_modules/ the pinned project dependency
54
- # 3. simt-dev on PATH a global install
78
+ # 3. ${CLI} on PATH a global install
55
79
  # If none resolves it REFUSES rather than exiting 0. A hook that cannot verify
56
80
  # anything must never report success - that is what teaches authors to reach for
57
81
  # --no-verify.
@@ -62,7 +86,7 @@ ROOT="${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)"
62
86
 
63
87
  ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
64
88
  subject: 'the dispatcher',
65
- remedy: `REMEDY set ${BIN_ENV} to the ${CLI} binary, or unset ${HOOKS_PATH_KEY} to opt out`,
89
+ remedy: `REMEDY run: ${install}, or set ${BIN_ENV} to the ${CLI} binary, or unset ${HOOKS_PATH_KEY} to opt out`,
66
90
  })}`;
67
91
  }
68
92
 
package/src/identity.mjs CHANGED
@@ -173,17 +173,6 @@ export async function includeOwnIdentity(root) {
173
173
  await git(root, ['config', '--local', '--replace-all', INCLUDE_KEY, `../${IDENTITY_FILE}`]);
174
174
  }
175
175
 
176
- /**
177
- * Whether the repository tracks the identity file, which it should not.
178
- *
179
- * @param {string} root
180
- * @returns {Promise<boolean>}
181
- */
182
- export async function tracksIdentity(root) {
183
- const listed = await git(root, ['ls-files', '--error-unmatch', IDENTITY_FILE]);
184
- return listed === IDENTITY_FILE;
185
- }
186
-
187
176
  /**
188
177
  * Add the identity file to .gitignore, if it is not already there.
189
178
  *
package/src/mcp/tools.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  // cause by accident because the caller cannot see the size until it has paid for it.
20
20
 
21
21
  import { fingerprint } from '../resolve/fingerprint.mjs';
22
- import { loadRegistry, REGISTRY_PATH } from '../graph/load.mjs';
22
+ import { loadRegistry } from '../graph/load.mjs';
23
23
  import { tokenize, rankSkills, closure, selectWithinBudget, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
24
24
  import { loadRules, select, checkTargets, provenanceDigest } from '../projection/project.mjs';
25
25
  import { loadDecisions, byId, citations } from '../decisions/index.mjs';
@@ -46,7 +46,7 @@ function rootOf(args) {
46
46
  }
47
47
 
48
48
  async function registry(args) {
49
- const { registry, errors, ok, verified, workspace } = await loadRegistry(REGISTRY_PATH, args ?? {});
49
+ const { registry, errors, ok, verified, workspace } = await loadRegistry(null, args ?? {});
50
50
  if (!ok) {
51
51
  throw Object.assign(new Error(errors[0]), {
52
52
  remedy: `run: ${CLI} graph lint`,
@@ -12,13 +12,18 @@ 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
  };
@@ -32,7 +37,7 @@ export function fail(message, code = EXIT.FAIL, remedy) {
32
37
  // default and it was only ever resolvable from inside simt-dev's own checkout;
33
38
  // a reader in a consumer repo got a remedy naming a file that does not exist
34
39
  // where they are. Every remedy here is something the reader can run.
35
- const resolved = remedy ?? REMEDY[code] ?? 'run: simt-dev --help';
40
+ const resolved = remedy ?? REMEDY[code] ?? `run: ${CLI} --help`;
36
41
  process.stderr.write(`ERROR ${code} ${message}\nREMEDY ${resolved}\n`);
37
42
  return code;
38
43
  }
@@ -60,10 +65,6 @@ export function writeMode(flags) {
60
65
  return { dryRun, apply: flags.yes === true && !dryRun };
61
66
  }
62
67
 
63
- export function isTty() {
64
- return process.stdout.isTTY === true && !process.env.NO_COLOR;
65
- }
66
-
67
68
  /**
68
69
  * Emit a block of text under a heading, respecting --max-bytes.
69
70
  * Truncation is always announced with an explicit line, never a silent ellipsis:
@@ -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
+ }