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.
- package/CHANGELOG.md +375 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -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/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +40 -0
- package/rules/stacks/deno.md +38 -0
- package/rules/stacks/dotnet.md +37 -0
- package/rules/stacks/elixir.md +41 -0
- package/rules/stacks/go.md +36 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +38 -0
- package/rules/stacks/rust.md +36 -0
- package/rules/stacks/swift.md +39 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +54 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +58 -25
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +37 -5
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +95 -69
- package/src/commands/init.mjs +100 -20
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +96 -60
- package/src/commands/skills.mjs +189 -86
- package/src/commands/skillsinstall.mjs +20 -5
- package/src/commands/sync.mjs +213 -15
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +107 -7
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +84 -7
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +290 -3
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +86 -9
- package/src/package-version.mjs +74 -0
- package/src/packs.mjs +182 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +335 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +139 -8
- package/src/projection/rules.mjs +308 -27
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +90 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- 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
|
+
}
|
package/src/hooks/capability.mjs
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/hooks/gates.mjs
CHANGED
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/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
|
+
}
|