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,68 @@
1
+ // Walking the registry to the files on disk, so the conformance checker sees text.
2
+ //
3
+ // `graph lint` already resolves every skill to a path and measures it; this module does
4
+ // the same walk for a different reason - it needs the bytes, not the count - and it is
5
+ // separate rather than folded into that pass because one of the two questions may run
6
+ // without the other.
7
+
8
+ import { readFile, stat } from 'node:fs/promises';
9
+ import { basename, join } from 'node:path';
10
+
11
+ import { checkSkill } from './spec.mjs';
12
+
13
+ /**
14
+ * Check every skill the registry names against the Agent Skills specification.
15
+ *
16
+ * Only the canonical source is checked, because a skill with two bodies would give two
17
+ * answers to one question and the reader would have to know which is which. A skill
18
+ * whose body cannot be read is a warning and not an error: the registry pass already
19
+ * reports a path that does not exist as an error, and saying it twice with two exit
20
+ * paths is the "two commands, two answers" defect this repository exists to end.
21
+ *
22
+ * @param {{skills: object[]}} registry
23
+ * @param {string} workspaceRoot
24
+ * @returns {Promise<{errors: string[], warnings: string[], checked: number}>}
25
+ */
26
+ export async function lintSpec(registry, workspaceRoot) {
27
+ const errors = [];
28
+ const warnings = [];
29
+ let checked = 0;
30
+
31
+ for (const skill of registry.skills ?? []) {
32
+ const source = (skill.sources ?? []).find((s) => s.role === 'canonical');
33
+ if (!source?.repo || !source?.path) continue;
34
+ const at = `${skill.name}: ${source.repo}/${source.path}`;
35
+ const full = join(workspaceRoot, source.repo, source.path);
36
+
37
+ let info;
38
+ try {
39
+ info = await stat(full);
40
+ } catch {
41
+ warnings.push(`${at}: the conformance pass could not read it; the registry pass above reports the path`);
42
+ continue;
43
+ }
44
+
45
+ // A skill directory holds its instructions in SKILL.md. A single file is the flat
46
+ // form ADR-009 says will be wrapped: it is still checked, because its frontmatter
47
+ // breaks the same rules, and the file name is reported as one of them.
48
+ const file = info.isDirectory() ? join(full, 'SKILL.md') : full;
49
+ let text;
50
+ try {
51
+ text = await readFile(file, 'utf8');
52
+ } catch {
53
+ warnings.push(`${at}: no SKILL.md in the directory, so there is nothing to check`);
54
+ continue;
55
+ }
56
+
57
+ const report = checkSkill({
58
+ directoryName: info.isDirectory() ? basename(full) : undefined,
59
+ fileName: basename(file),
60
+ text,
61
+ at,
62
+ });
63
+ errors.push(...report.errors);
64
+ warnings.push(...report.warnings);
65
+ checked += 1;
66
+ }
67
+ return { errors, warnings, checked };
68
+ }
@@ -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.
@@ -19,19 +19,20 @@
19
19
  // its reason, so "passed" can never be mistaken for "only some of it passed".
20
20
 
21
21
  import { execFile } from 'node:child_process';
22
- import { readFile } from 'node:fs/promises';
23
- import { basename, join } from 'node:path';
22
+ import { readFile, stat } from 'node:fs/promises';
23
+ import { basename, join, resolve, sep } from 'node:path';
24
24
  import { promisify } from 'node:util';
25
25
 
26
26
  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,10 +66,31 @@ 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();
71
87
  const commands = manifest?.gates?.commands;
88
+ // `gates.cwd` is a separate map rather than a second shape for `gates.commands`.
89
+ // A monorepo needs the gate to run inside one package, and the reader declaring it
90
+ // already knows the gate by name — putting the directory beside the command would
91
+ // mean re-reading the command string to find out where anything runs, and a manifest
92
+ // written by an older release would have to be rewritten to gain the capability.
93
+ const cwds = manifest?.gates?.cwd ?? {};
72
94
  const results = [];
73
95
  // What the format gate rewrote, so restage touches those files and nothing else.
74
96
  let formatted = [];
@@ -131,7 +153,33 @@ export async function runGates(event, { root, manifest }) {
131
153
  formatted = targets;
132
154
  }
133
155
 
134
- const outcome = await run(resolution.argv, root);
156
+ // The gate's directory, if it declares one. Resolved against the repo root and
157
+ // checked for containment: a `cwd` that escaped the repository would run a command
158
+ // against files this commit cannot see, and the gate would be green about them.
159
+ // Containment is checked on the RESOLVED path, not the string, because `..` and a
160
+ // symlink both get past a prefix test on the text.
161
+ let cwd = root;
162
+ const declaredCwd = cwds[name];
163
+ if (declaredCwd !== undefined) {
164
+ const resolved = resolve(root, declaredCwd);
165
+ const inside = resolved === root || resolved.startsWith(`${root}${sep}`);
166
+ const exists = inside && (await stat(resolved).catch(() => null))?.isDirectory();
167
+ if (!exists) {
168
+ results.push({
169
+ name,
170
+ status: 'failed',
171
+ detail: inside
172
+ ? `gates.cwd["${name}"] is ${declaredCwd}, which is not a directory in this repository`
173
+ : `gates.cwd["${name}"] is ${declaredCwd}, which is outside the repository`,
174
+ remedy: `point gates.cwd["${name}"] at a directory in this repository, or remove it to run the gate at the root`,
175
+ adr: gate.adr,
176
+ });
177
+ continue;
178
+ }
179
+ cwd = resolved;
180
+ }
181
+
182
+ const outcome = await runGate(resolution, cwd);
135
183
  results.push({
136
184
  name,
137
185
  status: outcome.code === 0 ? 'passed' : 'failed',
@@ -186,6 +234,37 @@ async function run(argv, cwd) {
186
234
  }
187
235
  }
188
236
 
237
+ /**
238
+ * Run a gate's resolution, as a shell line or as an argv.
239
+ *
240
+ * A declared command is a line the repository wrote in its own manifest and would type
241
+ * into its own shell (ADR-019 ruling 8), so it goes through `sh -c` and everything it
242
+ * wrote survives — quotes, pipes, `&&`, a flag with a space in its value. Everything
243
+ * this tool resolved itself stays an argv, because that is a path this tool chose out of
244
+ * `node_modules/.bin` and handing it to a shell would be a change of trust for no gain.
245
+ *
246
+ * `sh -c` is not a sandbox and is not meant to be. The trust boundary is the repository:
247
+ * the line came from its own manifest, which it can read and change, and it runs on the
248
+ * author's own machine at the author's own request. The only property worth defending is
249
+ * that the two sources stay distinguishable, which is why this is a separate branch
250
+ * rather than a flag on `run`.
251
+ */
252
+ async function runGate(resolution, cwd) {
253
+ if (resolution.shell === true && typeof resolution.command === 'string') {
254
+ try {
255
+ const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, maxBuffer: 8 * 1024 * 1024 });
256
+ return { code: 0, stdout, stderr };
257
+ } catch (error) {
258
+ return {
259
+ code: typeof error.code === 'number' ? error.code : 1,
260
+ stdout: error.stdout ?? '',
261
+ stderr: error.stderr ?? String(error.message ?? error),
262
+ };
263
+ }
264
+ }
265
+ return run(resolution.argv, cwd);
266
+ }
267
+
189
268
  async function readPkg(root) {
190
269
  try {
191
270
  return JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
@@ -212,7 +291,7 @@ export async function stagedFiles(root) {
212
291
  * A path staged as DELETED is not here: the index and the working tree agree that
213
292
  * it is gone, so there is no unstaged work to protect and no reason to refuse.
214
293
  */
215
- export async function unstagedFiles(root) {
294
+ async function unstagedFiles(root) {
216
295
  try {
217
296
  const { stdout } = await exec('git', ['diff', '--name-only'], { cwd: root });
218
297
  return new Set(stdout.split('\n').map((s) => s.trim()).filter(Boolean));
@@ -264,5 +343,3 @@ function tail(text, lines = 12) {
264
343
  const parts = String(text).trimEnd().split('\n');
265
344
  return parts.slice(-lines).join('\n');
266
345
  }
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
  *
@@ -0,0 +1,139 @@
1
+ // A JSON Schema checker, for the subset these schemas use.
2
+ //
3
+ // Written here rather than taken from npm because a tool that validates a payload has
4
+ // to keep working on a machine where nothing was installed (ADR-012), and because the
5
+ // schemas it checks are in this file: a dependency would be a second place for the same
6
+ // rules to live, and the one thing a validator must not be is quietly out of date.
7
+ //
8
+ // The subset is deliberately small and deliberately loud. A keyword this file does not
9
+ // understand is reported as an error rather than skipped, because a keyword silently
10
+ // ignored turns a schema into decoration - the check passes, the payload does not match,
11
+ // and nobody can say which of the two was wrong. `assertValid` is the only entry point
12
+ // the tests use, and it names the failing path.
13
+ //
14
+ // Supported: type (including union arrays), properties, required, items, enum,
15
+ // additionalProperties (boolean or a schema), anyOf.
16
+
17
+ /**
18
+ * Check a value against a schema.
19
+ *
20
+ * @param {unknown} value
21
+ * @param {object} schema
22
+ * @param {string} [path] JSON pointer-ish location, for the message
23
+ * @returns {string|null} the first failure, or null when it validates
24
+ */
25
+ export function check(value, schema, path = '$') {
26
+ if (schema === true || schema === undefined) return null;
27
+ if (schema === false) return `${path}: no value is allowed here`;
28
+ if (typeof schema !== 'object' || schema === null) return `${path}: schema must be an object`;
29
+
30
+ for (const keyword of Object.keys(schema)) {
31
+ if (!SUPPORTED.has(keyword)) {
32
+ // Loud on purpose: see the header. A keyword nobody implements is a promise the
33
+ // schema does not keep, and this is the only place that can notice.
34
+ throw new Error(`schema at ${path} uses "${keyword}", which this checker does not implement`);
35
+ }
36
+ }
37
+
38
+ if (schema.type !== undefined && !typeMatches(value, schema.type)) {
39
+ return `${path}: expected ${describe(schema.type)}, got ${describeValue(value)}`;
40
+ }
41
+ if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => same(allowed, value))) {
42
+ return `${path}: ${JSON.stringify(value)} is not one of ${JSON.stringify(schema.enum)}`;
43
+ }
44
+ if (Array.isArray(schema.anyOf)) {
45
+ const matched = schema.anyOf.some((option) => check(value, option, path) === null);
46
+ if (!matched) return `${path}: matched none of the ${schema.anyOf.length} allowed shapes`;
47
+ }
48
+
49
+ if (schema.type === 'object' || schema.properties !== undefined || schema.required !== undefined) {
50
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
51
+ for (const key of schema.required ?? []) {
52
+ if (!(key in value)) return `${path}: missing required property "${key}"`;
53
+ }
54
+ for (const [key, sub] of Object.entries(schema.properties ?? {})) {
55
+ if (!(key in value)) continue;
56
+ const failure = check(value[key], sub, `${path}.${key}`);
57
+ if (failure !== null) return failure;
58
+ }
59
+ if (schema.additionalProperties !== undefined) {
60
+ const known = new Set(Object.keys(schema.properties ?? {}));
61
+ for (const [key, sub] of Object.entries(value)) {
62
+ if (known.has(key)) continue;
63
+ if (schema.additionalProperties === false) return `${path}: unexpected property "${key}"`;
64
+ if (schema.additionalProperties !== true) {
65
+ const failure = check(sub, schema.additionalProperties, `${path}.${key}`);
66
+ if (failure !== null) return failure;
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ if (schema.items !== undefined && Array.isArray(value)) {
73
+ for (const [index, item] of value.entries()) {
74
+ const failure = check(item, schema.items, `${path}[${index}]`);
75
+ if (failure !== null) return failure;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** Throws with the first failure, named. The shape a test wants. */
82
+ export function assertValid(value, schema, what = 'value') {
83
+ const failure = check(value, schema);
84
+ if (failure !== null) throw new Error(`${what} does not match its schema: ${failure}`);
85
+ return true;
86
+ }
87
+
88
+ const SUPPORTED = new Set([
89
+ 'type',
90
+ 'properties',
91
+ 'required',
92
+ 'items',
93
+ 'enum',
94
+ 'additionalProperties',
95
+ 'anyOf',
96
+ 'description',
97
+ '$schema',
98
+ ]);
99
+
100
+ function typeMatches(value, type) {
101
+ const types = Array.isArray(type) ? type : [type];
102
+ return types.some((t) => singleTypeMatches(value, t));
103
+ }
104
+
105
+ function singleTypeMatches(value, type) {
106
+ switch (type) {
107
+ case 'object':
108
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
109
+ case 'array':
110
+ return Array.isArray(value);
111
+ case 'string':
112
+ return typeof value === 'string';
113
+ case 'number':
114
+ return typeof value === 'number' && Number.isFinite(value);
115
+ case 'integer':
116
+ return Number.isInteger(value);
117
+ case 'boolean':
118
+ return typeof value === 'boolean';
119
+ case 'null':
120
+ return value === null;
121
+ default:
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function describe(type) {
127
+ return Array.isArray(type) ? type.join('|') : type;
128
+ }
129
+
130
+ /** What a value IS, for the failure message. Distinct from `describe(type)`. */
131
+ function describeValue(value) {
132
+ if (value === null) return 'null';
133
+ if (Array.isArray(value)) return 'array';
134
+ return typeof value;
135
+ }
136
+
137
+ function same(a, b) {
138
+ return JSON.stringify(a) === JSON.stringify(b);
139
+ }