simt-dev 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +173 -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/README.md +17 -0
- 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 +173 -57
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +89 -13
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +3 -3
- package/src/commands/sync.mjs +153 -11
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +34 -5
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +50 -5
- 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/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +247 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/targets.mjs +53 -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
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`);
|
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
|
@@ -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
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
|
*
|
package/src/mcp/tools.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// cause by accident because the caller cannot see the size until it has paid for it.
|
|
20
20
|
|
|
21
21
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
22
|
-
import { loadRegistry
|
|
22
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
23
23
|
import { tokenize, rankSkills, closure, selectWithinBudget, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
|
|
24
24
|
import { loadRules, select, checkTargets, provenanceDigest } from '../projection/project.mjs';
|
|
25
25
|
import { loadDecisions, byId, citations } from '../decisions/index.mjs';
|
|
@@ -46,7 +46,7 @@ function rootOf(args) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async function registry(args) {
|
|
49
|
-
const { registry, errors, ok, verified, workspace } = await loadRegistry(
|
|
49
|
+
const { registry, errors, ok, verified, workspace } = await loadRegistry(null, args ?? {});
|
|
50
50
|
if (!ok) {
|
|
51
51
|
throw Object.assign(new Error(errors[0]), {
|
|
52
52
|
remedy: `run: ${CLI} graph lint`,
|
package/src/output/index.mjs
CHANGED
|
@@ -12,13 +12,18 @@ export const EXIT = {
|
|
|
12
12
|
OK: 0,
|
|
13
13
|
FAIL: 1, // a gate failed: drift, policy violation, missing evidence
|
|
14
14
|
USAGE: 2, // bad arguments; the remedy is in the message
|
|
15
|
-
CONFIG: 3, // simt
|
|
15
|
+
CONFIG: 3, // simt.json missing or invalid
|
|
16
16
|
REGISTRY: 4, // skills.graph.json invalid: orphan, dangling edge, cycle
|
|
17
17
|
UNSUPPORTED: 5, // the request cannot be satisfied on this platform or repo
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
const REMEDY = {
|
|
21
|
-
|
|
21
|
+
// Every default remedy below is `${CLI}` and not the package name, because this
|
|
22
|
+
// package installs exactly one binary and `package.json`'s `bin` map says which:
|
|
23
|
+
// `simt`. A remedy naming `simt-dev` is a command the reader types and gets
|
|
24
|
+
// `unknown command` from, which is the one answer that leaves them with no next
|
|
25
|
+
// step at all - and it was this default, printed on every bad invocation.
|
|
26
|
+
[EXIT.USAGE]: `run: ${CLI} --help`,
|
|
22
27
|
[EXIT.CONFIG]: `run: ${CLI} init`,
|
|
23
28
|
[EXIT.REGISTRY]: `run: ${CLI} graph lint`,
|
|
24
29
|
};
|
|
@@ -32,7 +37,7 @@ export function fail(message, code = EXIT.FAIL, remedy) {
|
|
|
32
37
|
// default and it was only ever resolvable from inside simt-dev's own checkout;
|
|
33
38
|
// a reader in a consumer repo got a remedy naming a file that does not exist
|
|
34
39
|
// where they are. Every remedy here is something the reader can run.
|
|
35
|
-
const resolved = remedy ?? REMEDY[code] ??
|
|
40
|
+
const resolved = remedy ?? REMEDY[code] ?? `run: ${CLI} --help`;
|
|
36
41
|
process.stderr.write(`ERROR ${code} ${message}\nREMEDY ${resolved}\n`);
|
|
37
42
|
return code;
|
|
38
43
|
}
|
|
@@ -60,10 +65,6 @@ export function writeMode(flags) {
|
|
|
60
65
|
return { dryRun, apply: flags.yes === true && !dryRun };
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
export function isTty() {
|
|
64
|
-
return process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
68
|
/**
|
|
68
69
|
* Emit a block of text under a heading, respecting --max-bytes.
|
|
69
70
|
* Truncation is always announced with an explicit line, never a silent ellipsis:
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// How simt-dev is installed, and the version a repository holds it to.
|
|
2
|
+
//
|
|
3
|
+
// Three distinct things that must not be confused:
|
|
4
|
+
// - packageVersion() is the version of the binary that is RUNNING. It is read from
|
|
5
|
+
// the package this code lives in, so it is correct whether the binary was resolved
|
|
6
|
+
// from node_modules, a global install, or a source checkout.
|
|
7
|
+
// - hasNpm(root) is the KIND of repository: does it have a package.json? That decides
|
|
8
|
+
// how it obtains simt-dev at all — pinned as a devDependency where one exists, a
|
|
9
|
+
// pinned global install where there is no node_modules to pin in.
|
|
10
|
+
// - the pin is the version a repository recorded when it adopted simt-dev, stored in
|
|
11
|
+
// the manifest's projection.version. It is what a global install must match: npm
|
|
12
|
+
// pins the node_modules copy for us, but nothing pins a global binary, so the gate
|
|
13
|
+
// compares the running version against the pin and refuses on a mismatch.
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { stat } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { MANIFEST_FILE } from './name.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The version of the simt-dev package this code is running from.
|
|
23
|
+
*
|
|
24
|
+
* Sync, because the render functions it feeds are sync: a rendered artifact bakes in
|
|
25
|
+
* the version that produced it, and that must be known before the bytes are written.
|
|
26
|
+
* A missing or unreadable package.json degrades to 'unknown' rather than throwing —
|
|
27
|
+
* the same call already tolerates it in `--version`, and a render that cannot read its
|
|
28
|
+
* own version is not a reason to crash the command that calls it.
|
|
29
|
+
*/
|
|
30
|
+
export function packageVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
33
|
+
return pkg.version;
|
|
34
|
+
} catch {
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a repository has a package.json, i.e. whether it pins simt-dev as a
|
|
41
|
+
* devDependency (npm) or must fall back to a global install. Async because the render
|
|
42
|
+
* call sites that feed it (init, hooks install, the compare stale check) are all async,
|
|
43
|
+
* and a missing file is an answer (false), not an error.
|
|
44
|
+
*/
|
|
45
|
+
export async function hasNpm(root) {
|
|
46
|
+
try {
|
|
47
|
+
await stat(join(root, 'package.json'));
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether the running binary matches the version a repository pinned when it adopted
|
|
56
|
+
* simt-dev. Returns null when there is nothing to refuse, otherwise a message that
|
|
57
|
+
* names both versions and the one command that fixes it.
|
|
58
|
+
*
|
|
59
|
+
* This is the gate's fail-closed guard against a stale global binary: for a repository
|
|
60
|
+
* without npm, simt-dev is a global install and the only thing that knows which version
|
|
61
|
+
* it should be is the pin. A node_modules copy is pinned by npm itself, so its mismatch
|
|
62
|
+
* is a stale manifest, which `upgrade` refreshes — the remedy is the same either way.
|
|
63
|
+
*
|
|
64
|
+
* A manifest with no recorded pin (adopted before the field existed) is skipped, not
|
|
65
|
+
* failed: there is nothing to compare against, and failing it would turn every pre-0.2
|
|
66
|
+
* repository into a red gate the moment this check ships.
|
|
67
|
+
*/
|
|
68
|
+
export function assertVersionPin(manifest) {
|
|
69
|
+
const pin = manifest?.projection?.version;
|
|
70
|
+
if (!pin || pin === 'unknown') return null;
|
|
71
|
+
const running = packageVersion();
|
|
72
|
+
if (running === pin) return null;
|
|
73
|
+
return `simt-dev version mismatch: ${MANIFEST_FILE} pins ${pin}, the running binary is ${running}`;
|
|
74
|
+
}
|