simt-dev 0.1.1 → 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 (91) hide show
  1. package/CHANGELOG.md +209 -7
  2. package/README.md +32 -5
  3. package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
  4. package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
  5. package/docs/decisions/ADR-003-test-breadth.md +4 -4
  6. package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
  7. package/docs/decisions/ADR-005-file-length.md +5 -5
  8. package/docs/decisions/ADR-006-coverage-bar.md +3 -3
  9. package/docs/decisions/ADR-007-section-skeleton.md +5 -5
  10. package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
  11. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  12. package/docs/decisions/ADR-010-runner-invocation.md +1 -1
  13. package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
  14. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
  15. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
  16. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
  17. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
  18. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
  19. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
  20. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
  21. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
  22. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  23. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
  24. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
  25. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
  26. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
  27. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
  28. package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
  29. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
  30. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
  31. package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  32. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  33. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  34. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  35. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  36. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  37. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  38. package/docs/decisions/README.md +20 -1
  39. package/package.json +1 -2
  40. package/rules/stacks/dart.md +38 -0
  41. package/rules/stacks/deno.md +36 -0
  42. package/rules/stacks/dotnet.md +35 -0
  43. package/rules/stacks/elixir.md +39 -0
  44. package/rules/stacks/go.md +34 -0
  45. package/rules/stacks/ruby.md +36 -0
  46. package/rules/stacks/rust.md +34 -0
  47. package/rules/stacks/swift.md +37 -0
  48. package/src/argv.mjs +35 -6
  49. package/src/ci/dependabot.mjs +1 -1
  50. package/src/ci/workflow.mjs +33 -11
  51. package/src/commands/graph.mjs +13 -4
  52. package/src/commands/help.mjs +22 -4
  53. package/src/commands/hooks.mjs +183 -67
  54. package/src/commands/index.mjs +53 -45
  55. package/src/commands/init.mjs +156 -14
  56. package/src/commands/rules.mjs +54 -3
  57. package/src/commands/skills.mjs +143 -15
  58. package/src/commands/skillsinstall.mjs +4 -4
  59. package/src/commands/sync.mjs +153 -11
  60. package/src/compare/discover.mjs +1 -1
  61. package/src/compare/hooksstate.mjs +96 -9
  62. package/src/compare/row.mjs +36 -7
  63. package/src/decisions/index.mjs +51 -12
  64. package/src/graph/load.mjs +126 -15
  65. package/src/hooks/capability.mjs +49 -5
  66. package/src/hooks/dispatcher.mjs +51 -6
  67. package/src/hooks/gates.mjs +1 -3
  68. package/src/hooks/resolve.mjs +24 -25
  69. package/src/hooks/shim.mjs +29 -5
  70. package/src/hooks/where.mjs +1 -1
  71. package/src/identity.mjs +207 -0
  72. package/src/mcp/tools.mjs +2 -2
  73. package/src/output/index.mjs +8 -7
  74. package/src/package-version.mjs +74 -0
  75. package/src/projection/baselines.mjs +1 -1
  76. package/src/projection/conflicts.mjs +57 -6
  77. package/src/projection/manifest.mjs +248 -26
  78. package/src/projection/markers.mjs +3 -3
  79. package/src/projection/mdc.mjs +6 -15
  80. package/src/projection/project.mjs +1 -1
  81. package/src/projection/rules.mjs +21 -8
  82. package/src/projection/surfaces.mjs +2 -2
  83. package/src/projection/targets.mjs +53 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +4 -3
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +2 -2
  89. package/src/tsconfig.mjs +1 -1
  90. package/src/workspace.mjs +0 -9
  91. 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`);
@@ -478,7 +589,7 @@ async function lintSourcesOnDisk(registry, workspaceRoot) {
478
589
  /**
479
590
  * SKILL.md plus every file beneath the skill directory.
480
591
  * Handles both layouts: a directory containing SKILL.md, and the flat
481
- * `skills/<name>.md` form bunnyland-server still uses (ADR-009 says that will be
592
+ * `skills/<name>.md` form one repository still uses (ADR-009 says that will be
482
593
  * wrapped, so lint reports it rather than accepting it silently).
483
594
  */
484
595
  export async function measureSkill(full) {
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // This exists because the first real adoption declared five gates and could run two.
4
4
  // The stack table proposes a formatter, a linter and an i18n check for any Node repo;
5
- // simt-social-media has no prettier installed, no `lint` script and no declared command
5
+ // a repository with no prettier installed, no `lint` script and no declared command
6
6
  // for the i18n check. Every one of those gates fails closed by design, so installing the
7
7
  // dispatcher on that manifest blocks every commit and teaches the team to reach for
8
8
  // `--no-verify`. The dispatcher was right; the declaration was wrong.
@@ -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.
@@ -3,7 +3,7 @@
3
3
  // Design commitments, all of them load-bearing:
4
4
  //
5
5
  // Fail closed, never fail open. A gate that cannot run is a failure, not a
6
- // skip. simt-space removed tests from pre-commit because they were slow, and
6
+ // skip. A repository removed tests from pre-commit because they were slow, and
7
7
  // the documented consequence was that authors reached for --no-verify. A
8
8
  // dispatcher that skips what it cannot do reproduces exactly that.
9
9
  //
@@ -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 };
@@ -100,7 +100,7 @@ export function getGate(name) {
100
100
  * This is the load-bearing decision of the whole dispatcher. A gate that
101
101
  * silently does nothing is indistinguishable from a gate that passed, and the
102
102
  * documented consequence in this estate is an agent reaching for
103
- * `--no-verify` - simt-space removed its tests from pre-commit precisely
103
+ * `--no-verify` - a repository removed its tests from pre-commit precisely
104
104
  * because they were slow, and slow-plus-silent is what teaches everyone that
105
105
  * hooks are advisory.
106
106
  */
@@ -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
 
@@ -55,7 +55,7 @@ export async function resolveHooksDir(root) {
55
55
  }
56
56
 
57
57
  // Rule 1: core.hooksPath wins. A relative value resolves against the top level
58
- // of the working tree, which is what makes simt-app's `hooks` work.
58
+ // of the working tree, which is what makes a `hooks` directory work.
59
59
  if (configured !== null) {
60
60
  return {
61
61
  dir: isAbsolute(configured) ? configured : resolve(top, configured),