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.
- package/CHANGELOG.md +209 -7
- package/README.md +32 -5
- package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
- package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
- package/docs/decisions/ADR-003-test-breadth.md +4 -4
- package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
- package/docs/decisions/ADR-005-file-length.md +5 -5
- package/docs/decisions/ADR-006-coverage-bar.md +3 -3
- package/docs/decisions/ADR-007-section-skeleton.md +5 -5
- package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-010-runner-invocation.md +1 -1
- package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
- package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
- package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
- package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
- package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
- package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
- package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
- package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
- package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
- package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
- package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
- package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/README.md +20 -1
- package/package.json +1 -2
- package/rules/stacks/dart.md +38 -0
- package/rules/stacks/deno.md +36 -0
- package/rules/stacks/dotnet.md +35 -0
- package/rules/stacks/elixir.md +39 -0
- package/rules/stacks/go.md +34 -0
- package/rules/stacks/ruby.md +36 -0
- package/rules/stacks/rust.md +34 -0
- package/rules/stacks/swift.md +37 -0
- package/src/argv.mjs +35 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/graph.mjs +13 -4
- package/src/commands/help.mjs +22 -4
- package/src/commands/hooks.mjs +183 -67
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +156 -14
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +4 -4
- package/src/commands/sync.mjs +153 -11
- package/src/compare/discover.mjs +1 -1
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +36 -7
- package/src/decisions/index.mjs +51 -12
- package/src/graph/load.mjs +126 -15
- package/src/hooks/capability.mjs +49 -5
- package/src/hooks/dispatcher.mjs +51 -6
- package/src/hooks/gates.mjs +1 -3
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/hooks/where.mjs +1 -1
- package/src/identity.mjs +207 -0
- package/src/mcp/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/conflicts.mjs +57 -6
- package/src/projection/manifest.mjs +248 -26
- package/src/projection/markers.mjs +3 -3
- package/src/projection/mdc.mjs +6 -15
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/surfaces.mjs +2 -2
- package/src/projection/targets.mjs +53 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +4 -3
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +2 -2
- package/src/tsconfig.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/src/graph/load.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
26
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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} (${
|
|
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
|
|
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) {
|
package/src/hooks/capability.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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 {
|
|
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.
|
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|
package/src/hooks/gates.mjs
CHANGED
|
@@ -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` -
|
|
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 };
|
package/src/hooks/resolve.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
+
}
|
package/src/hooks/shim.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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/hooks/where.mjs
CHANGED
|
@@ -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
|
|
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),
|