simt-dev 0.2.1 → 0.3.0
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 +239 -0
- package/bin/simt-dev.mjs +21 -2
- 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/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/package.json +2 -1
- 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 +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -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 +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +55 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +359 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +301 -0
- package/src/commands/help.mjs +73 -2
- package/src/commands/index.mjs +67 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/plan.mjs +99 -0
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +169 -0
- package/src/compare/row.mjs +73 -2
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +228 -20
- package/src/mcp/tools.mjs +372 -1
- package/src/measure/context.mjs +542 -0
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/manifest.mjs +114 -3
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +365 -22
- package/src/projection/surfaces.mjs +155 -23
- package/src/projection/targets.mjs +37 -0
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Agent Skills conformance, checked against the specification.
|
|
2
|
+
//
|
|
3
|
+
// Every rule here was read from agentskills.io rather than recalled, for the reason
|
|
4
|
+
// ADR-067 records: a lint that checks the wrong rule is worse than no lint, because it
|
|
5
|
+
// reports a clean file that a client will reject.
|
|
6
|
+
//
|
|
7
|
+
// The distinction the file draws is the one the specification draws. A "must" is an error
|
|
8
|
+
// and stops the exit code; a "should" or a recommendation is a warning and does not. An
|
|
9
|
+
// error the reader cannot act on is a papercut that trains people to skip the gate, so
|
|
10
|
+
// every message names the rule it broke and the fix.
|
|
11
|
+
|
|
12
|
+
/** The one specification this knows, and the name `--spec` accepts. */
|
|
13
|
+
export const SPEC = 'agentskills';
|
|
14
|
+
|
|
15
|
+
export const SPEC_SOURCE = 'agentskills.io/specification';
|
|
16
|
+
|
|
17
|
+
/** Fields the specification defines. Anything else in the frontmatter is unknown. */
|
|
18
|
+
const SPEC_FIELDS = ['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools'];
|
|
19
|
+
|
|
20
|
+
/** Tokens, not bytes, because the specification counts tokens and every number here is
|
|
21
|
+
* the same four-bytes-per-token estimate the context meter uses. */
|
|
22
|
+
const BODY_TOKEN_BUDGET = 5000;
|
|
23
|
+
const BODY_LINE_BUDGET = 500;
|
|
24
|
+
const BYTES_PER_TOKEN = 4;
|
|
25
|
+
|
|
26
|
+
/** The `name` rules, as one predicate with the rule it enforces. */
|
|
27
|
+
const NAME_RULES = {
|
|
28
|
+
max: 64,
|
|
29
|
+
// Lowercase alphanumerics and hyphens only. Unicode lowercase is deliberately NOT
|
|
30
|
+
// allowed even though the specification says "unicode lowercase alphanumeric": a
|
|
31
|
+
// skill directory travels between machines and shells, and the subset every client
|
|
32
|
+
// accepts is the ASCII one. Reported as the ASCII rule because that is the one a
|
|
33
|
+
// reader can fix without reading a Unicode table.
|
|
34
|
+
pattern: /^[a-z0-9-]+$/,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The first naming rule `name` breaks, or null when it breaks none.
|
|
39
|
+
*
|
|
40
|
+
* Exported because two commands need the same answer: the lint, which reports it, and
|
|
41
|
+
* `skills install`, which refuses to create a directory a client could never address by
|
|
42
|
+
* the name inside it. Two copies of these rules would be two sets, and the one nobody
|
|
43
|
+
* linted would be the one that let an unaddressable skill through.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} name
|
|
46
|
+
* @returns {string|null} the rule, phrased as a defect
|
|
47
|
+
*/
|
|
48
|
+
export function nameProblems(name) {
|
|
49
|
+
if (name.length === 0) return 'is empty; the specification requires 1 to 64 characters';
|
|
50
|
+
if (name.length > NAME_RULES.max) {
|
|
51
|
+
return `is ${name.length} characters; the specification allows at most ${NAME_RULES.max}`;
|
|
52
|
+
}
|
|
53
|
+
if (!NAME_RULES.pattern.test(name)) return `is not lowercase letters, digits and hyphens`;
|
|
54
|
+
if (name.startsWith('-') || name.endsWith('-')) return `starts or ends with a hyphen`;
|
|
55
|
+
if (name.includes('--')) return `contains consecutive hyphens`;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check one skill against the specification.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} input
|
|
63
|
+
* @param {string} input.directoryName the directory the skill lives in
|
|
64
|
+
* @param {string} [input.fileName] the file the frontmatter came from
|
|
65
|
+
* @param {string} input.text the whole SKILL.md
|
|
66
|
+
* @param {string} input.at how the reader names this skill in a finding
|
|
67
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
68
|
+
*/
|
|
69
|
+
export function checkSkill({ directoryName, fileName = 'SKILL.md', text, at }) {
|
|
70
|
+
const errors = [];
|
|
71
|
+
const warnings = [];
|
|
72
|
+
const fail = (detail) => errors.push(`${at}: ${detail}`);
|
|
73
|
+
const warn = (detail) => warnings.push(`${at}: ${detail}`);
|
|
74
|
+
|
|
75
|
+
if (fileName !== 'SKILL.md') {
|
|
76
|
+
// The layout is part of the specification, not a naming preference: a client looks
|
|
77
|
+
// for that file by name. ADR-009's flat `skills/<name>.md` form is the case here,
|
|
78
|
+
// and it is reported rather than accepted.
|
|
79
|
+
fail(`the specification requires the file to be named SKILL.md; this skill is a single ${fileName}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { meta, body, present } = readFrontmatter(text);
|
|
83
|
+
|
|
84
|
+
// --- name -------------------------------------------------------------------
|
|
85
|
+
const name = meta.name ?? '';
|
|
86
|
+
if (name.length === 0) {
|
|
87
|
+
fail('frontmatter has no `name`, which the specification requires');
|
|
88
|
+
} else {
|
|
89
|
+
const broken = nameProblems(name);
|
|
90
|
+
if (broken !== null) fail(`name "${name}" ${broken}`);
|
|
91
|
+
// The rule the review did not list and the one that breaks installs: a directory
|
|
92
|
+
// and a name that disagree cannot both be addressed, and the client that resolves
|
|
93
|
+
// by name never finds the directory it was given.
|
|
94
|
+
if (directoryName !== undefined && name !== directoryName) {
|
|
95
|
+
fail(`name "${name}" does not match its directory "${directoryName}"; the specification requires them to be equal`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- description ------------------------------------------------------------
|
|
100
|
+
const description = meta.description ?? '';
|
|
101
|
+
if (description.length === 0) {
|
|
102
|
+
fail('frontmatter has no `description`, which the specification requires');
|
|
103
|
+
} else if (description.length > 1024) {
|
|
104
|
+
fail(`description is ${description.length} characters; the specification allows at most 1024`);
|
|
105
|
+
} else if (description.length < 30) {
|
|
106
|
+
// A recommendation, not a rule: the specification asks for what the skill does and
|
|
107
|
+
// when to use it, and a client matching on keywords finds nothing in three words.
|
|
108
|
+
warn(`description is ${description.length} characters; it should say what the skill does AND when to use it`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- compatibility ----------------------------------------------------------
|
|
112
|
+
const compatibility = meta.compatibility;
|
|
113
|
+
if (compatibility !== undefined && String(compatibility).length > 500) {
|
|
114
|
+
fail(`compatibility is ${String(compatibility).length} characters; the specification allows at most 500`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- metadata ---------------------------------------------------------------
|
|
118
|
+
// Stated as a map from string keys to string values, but not as a hard requirement,
|
|
119
|
+
// so a value of another type is a warning. A client that reads it strictly will drop
|
|
120
|
+
// the field, and the reader deserves to know before a client does.
|
|
121
|
+
for (const [key, value] of Object.entries(meta.metadata ?? {})) {
|
|
122
|
+
if (typeof value !== 'string') {
|
|
123
|
+
warn(`metadata.${key} is ${typeof value}, not a string; the specification describes a map of string keys to string values`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- unknown fields ---------------------------------------------------------
|
|
128
|
+
for (const key of present) {
|
|
129
|
+
if (!SPEC_FIELDS.includes(key)) {
|
|
130
|
+
warn(`frontmatter carries "${key}", which the specification does not define`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- body -------------------------------------------------------------------
|
|
135
|
+
const bytes = Buffer.byteLength(text);
|
|
136
|
+
const tokens = Math.ceil(bytes / BYTES_PER_TOKEN);
|
|
137
|
+
if (tokens > BODY_TOKEN_BUDGET) {
|
|
138
|
+
warn(`body is ~${tokens} tokens (${bytes} bytes); the specification recommends under ${BODY_TOKEN_BUDGET}, with detail in reference files`);
|
|
139
|
+
}
|
|
140
|
+
const lines = body.split('\n').length;
|
|
141
|
+
if (lines > BODY_LINE_BUDGET) {
|
|
142
|
+
warn(`body is ${lines} lines; the specification recommends keeping SKILL.md under ${BODY_LINE_BUDGET}`);
|
|
143
|
+
}
|
|
144
|
+
// The three progressive-disclosure numbers, reported as one line so a reader sees
|
|
145
|
+
// the cost before the fix rather than one warning at a time.
|
|
146
|
+
if (tokens > BODY_TOKEN_BUDGET) {
|
|
147
|
+
const perHundred = Math.round(tokens / (lines / 100));
|
|
148
|
+
warn(`~${perHundred} tokens per 100 lines: the agent loads all of this once the skill activates, and reference files load only when asked`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { errors, warnings };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Frontmatter as flat scalars, folded block scalars, plus a nested `metadata` map.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately its own reader rather than the two that live in the projection: those
|
|
158
|
+
* strip the block and keep the values this file never looks at, and one of them would
|
|
159
|
+
* have to grow a block-scalar branch for a second caller.
|
|
160
|
+
*
|
|
161
|
+
* The block scalar is here because it is not optional: `description: >-` followed by
|
|
162
|
+
* indented lines is the spelling most real skills use for a long description, and a
|
|
163
|
+
* reader that took `>-` as the value reported a 200-character description as two
|
|
164
|
+
* characters. That is the whole failure this file exists to prevent, one level down - a
|
|
165
|
+
* lint that is confidently wrong about a conforming file.
|
|
166
|
+
*/
|
|
167
|
+
function readFrontmatter(text) {
|
|
168
|
+
const meta = {};
|
|
169
|
+
const present = [];
|
|
170
|
+
const body = text.replace(/^/, '');
|
|
171
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(body);
|
|
172
|
+
if (!match) return { meta, present, body };
|
|
173
|
+
const lines = match[1].split(/\r?\n/);
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
176
|
+
const line = lines[i];
|
|
177
|
+
if (/^\s/.test(line)) continue; // Consumed by the block it belongs to, or metadata.
|
|
178
|
+
const field = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line);
|
|
179
|
+
if (!field) continue;
|
|
180
|
+
const [, key, raw] = field;
|
|
181
|
+
present.push(key);
|
|
182
|
+
|
|
183
|
+
const block = /^([|>])([-+]?)\s*$/.exec(raw.trim());
|
|
184
|
+
if (block !== null) {
|
|
185
|
+
const folded = block[1] === '>';
|
|
186
|
+
const collected = [];
|
|
187
|
+
while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === '')) {
|
|
188
|
+
const next = lines[i + 1];
|
|
189
|
+
if (next.trim() === '' && !/^\s/.test(next)) break;
|
|
190
|
+
collected.push(next.trim());
|
|
191
|
+
i += 1;
|
|
192
|
+
if (i + 1 >= lines.length) break;
|
|
193
|
+
if (!/^\s/.test(lines[i + 1]) && lines[i + 1].trim() !== '') break;
|
|
194
|
+
}
|
|
195
|
+
// A folded scalar joins its lines with spaces; a literal one keeps the newlines.
|
|
196
|
+
// Either way the blank lines a paragraph break introduced become a single space,
|
|
197
|
+
// because the count that matters here is characters, not layout.
|
|
198
|
+
meta[key] = collected.join(folded ? ' ' : '\n').replace(/\s+/g, folded ? ' ' : '\n').trim();
|
|
199
|
+
if (key === 'metadata') meta.metadata = {};
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (raw.trim() === '') {
|
|
204
|
+
meta[key] = '';
|
|
205
|
+
if (key === 'metadata') {
|
|
206
|
+
meta.metadata = {};
|
|
207
|
+
// An indented `key: value` block belongs to it.
|
|
208
|
+
while (i + 1 < lines.length && /^\s+/.test(lines[i + 1])) {
|
|
209
|
+
const nested = /^\s+([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(lines[i + 1]);
|
|
210
|
+
if (nested && key === 'metadata') meta.metadata[nested[1]] = scalar(nested[2]);
|
|
211
|
+
i += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
meta[key] = scalar(raw);
|
|
217
|
+
}
|
|
218
|
+
return { meta, present, body: body.slice(match[0].length) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Strip the quoting and the trailing comment a scalar may carry. */
|
|
222
|
+
function scalar(raw) {
|
|
223
|
+
const value = raw.trim().replace(/\s+#.*$/, '').trim();
|
|
224
|
+
if (value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1);
|
|
225
|
+
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
@@ -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/dispatcher.mjs
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
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';
|
|
@@ -85,6 +85,12 @@ export async function runGates(event, { root, manifest }) {
|
|
|
85
85
|
const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
|
|
86
86
|
const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
|
|
87
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 ?? {};
|
|
88
94
|
const results = [];
|
|
89
95
|
// What the format gate rewrote, so restage touches those files and nothing else.
|
|
90
96
|
let formatted = [];
|
|
@@ -147,7 +153,33 @@ export async function runGates(event, { root, manifest }) {
|
|
|
147
153
|
formatted = targets;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
|
|
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);
|
|
151
183
|
results.push({
|
|
152
184
|
name,
|
|
153
185
|
status: outcome.code === 0 ? 'passed' : 'failed',
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// The command card: the exact commands for THIS repository, at the top of the
|
|
2
|
+
// projected region (plan 0003 R10).
|
|
3
|
+
//
|
|
4
|
+
// The card holds the bytes a reader cannot infer. "Run the tests" is useless advice in a
|
|
5
|
+
// repository whose test command is `./gradlew :app:testDebugUnitTest`; the runner, the
|
|
6
|
+
// wrapper and the task name are facts about this repository, and none of them are in the
|
|
7
|
+
// rules, which are the same in every repository. So the card is derived rather than
|
|
8
|
+
// written: from the manifest's declared gates, the repository's own `package.json`
|
|
9
|
+
// scripts, and the marker files that name its ecosystem.
|
|
10
|
+
//
|
|
11
|
+
// It reads committed evidence ONLY. It must never consult PATH or `node_modules`, because
|
|
12
|
+
// the projected region is required to be byte-identical on two machines (plan 0003 P4,
|
|
13
|
+
// R13) and a card that named whichever linter happened to be installed would differ
|
|
14
|
+
// between a laptop and CI for no reason a diff could explain. Runnability is a property
|
|
15
|
+
// of the machine; a command is a property of the repository.
|
|
16
|
+
//
|
|
17
|
+
// The focused-test entries are TEMPLATES. `{file}`, `{pkg}`, `{Test}` and `{class}` are
|
|
18
|
+
// placeholders the reader fills from the file they are editing, and the label names the
|
|
19
|
+
// path kind so a template is never mistaken for a command that can be pasted as-is.
|
|
20
|
+
// Refining which test a given `{file}` maps to is R24's job, not R10's.
|
|
21
|
+
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { ECOSYSTEMS } from '../projection/manifest.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The card's ceiling in bytes (plan 0003 R10). Over it, the least valuable lines are
|
|
29
|
+
* dropped rather than the ceiling raised: the point of the card is the bytes a reader
|
|
30
|
+
* most needs, and an unbounded card is a second copy of the rules.
|
|
31
|
+
*/
|
|
32
|
+
export const CARD_LIMIT_BYTES = 600;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The verbs the card carries, in the order a reader meets them. The plan names exactly
|
|
36
|
+
* these five. A `build` command exists only where a repository declares one, so the order
|
|
37
|
+
* is a priority as well as a sequence: the most universally true verb comes first.
|
|
38
|
+
*
|
|
39
|
+
* Exported because a second reader has to agree with it: a rule may declare `card:<verb>`
|
|
40
|
+
* as the evidence that makes its line redundant (R09), and a copy of this list living in
|
|
41
|
+
* the filter would let the card name a verb the filter called unknown.
|
|
42
|
+
*/
|
|
43
|
+
export const CARD_VERBS = ['build', 'test', 'lint', 'format', 'typecheck'];
|
|
44
|
+
|
|
45
|
+
const VERBS = CARD_VERBS;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The order verbs are dropped in when the card is over its ceiling: the verb whose loss a
|
|
49
|
+
* reader misses least goes first. `test` is absent on purpose — a card that dropped the
|
|
50
|
+
* test command would have dropped the one command it exists to carry.
|
|
51
|
+
*/
|
|
52
|
+
const DROP_ORDER = ['format', 'typecheck', 'lint', 'build'];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build commands for ecosystems whose build entrypoint is not a package script. Keyed by
|
|
56
|
+
* a marker fact or stack tag, whichever the ecosystem is identified by. `npm` is handled
|
|
57
|
+
* before this table, because a Node repository states its own build script and stating it
|
|
58
|
+
* again here would be a second answer to the same question.
|
|
59
|
+
*/
|
|
60
|
+
const BUILDS = [
|
|
61
|
+
{ fact: 'has-cargo-toml', command: 'cargo build' },
|
|
62
|
+
{ fact: 'has-go-mod', command: 'go build ./...' },
|
|
63
|
+
{ fact: 'has-nuget', command: 'dotnet build' },
|
|
64
|
+
{ fact: 'has-swift-package', command: 'swift build' },
|
|
65
|
+
{ fact: 'has-mix', command: 'mix compile' },
|
|
66
|
+
{ stack: 'gradle', command: './gradlew build' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One focused-test template per path kind. Entries are ordered, and the FIRST match wins,
|
|
71
|
+
* so a repository that carries more than one marker gets the more specific template:
|
|
72
|
+
* `has-uv-lock` before `has-pyproject`, `has-android-manifest` before the plain `gradle`
|
|
73
|
+
* tag, `has-deno-config` before the Node fallback. A duplicate template for the same path
|
|
74
|
+
* kind would be two answers, and the second is the wrong one.
|
|
75
|
+
*/
|
|
76
|
+
const FOCUSED = [
|
|
77
|
+
{ kind: 'Android', when: (s, f) => f.has('has-android-manifest'), template: () => './gradlew :{module}:testDebugUnitTest --tests {class}' },
|
|
78
|
+
{ kind: 'JVM', when: (s) => s.has('gradle'), template: () => './gradlew test --tests {class}' },
|
|
79
|
+
{ kind: 'Maven', when: (s, f) => f.has('has-maven-pom'), template: () => './mvnw test -Dtest={class}' },
|
|
80
|
+
{ kind: 'Go package', when: (s, f) => f.has('has-go-mod'), template: () => 'go test ./{pkg} -run {Test}' },
|
|
81
|
+
{ kind: 'Rust', when: (s, f) => f.has('has-cargo-toml'), template: () => 'cargo test {name}' },
|
|
82
|
+
{ kind: 'Python', when: (s, f) => f.has('has-uv-lock'), template: () => 'uv run -m pytest {file}::{test}' },
|
|
83
|
+
{ kind: 'Python', when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), template: () => 'pytest {file}::{test}' },
|
|
84
|
+
{ kind: '.NET', when: (s, f) => f.has('has-nuget'), template: () => 'dotnet test --filter {test}' },
|
|
85
|
+
{ kind: 'Swift', when: (s, f) => f.has('has-swift-package'), template: () => 'swift test --filter {test}' },
|
|
86
|
+
{ kind: 'Deno', when: (s, f) => f.has('has-deno-config'), template: () => 'deno test {file}' },
|
|
87
|
+
{ kind: 'Ruby', when: (s, f) => f.has('has-gemfile'), template: () => 'bundle exec rspec {file}' },
|
|
88
|
+
{ kind: 'Dart', when: (s, f) => f.has('has-pubspec'), template: () => 'dart test {file}' },
|
|
89
|
+
{ kind: 'Elixir', when: (s, f) => f.has('has-mix'), template: () => 'mix test {file}' },
|
|
90
|
+
{
|
|
91
|
+
kind: 'Node',
|
|
92
|
+
when: (s) => s.has('node'),
|
|
93
|
+
// The repo's own runner decides the shape. A repository whose test script is vitest
|
|
94
|
+
// or jest takes a file argument through the script; one with no such script gets the
|
|
95
|
+
// built-in runner, which is the only answer that needs nothing installed to already
|
|
96
|
+
// be true. `npm test -- <file>` is the ADR-010 shape (never `npx`).
|
|
97
|
+
template: (s, f, scripts) => {
|
|
98
|
+
const test = scripts?.test ?? '';
|
|
99
|
+
return /vitest|jest/.test(test) ? 'npm test -- {file}' : 'node --test {file}';
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verbs for ecosystems the gate table does not seed, used by the card alone.
|
|
106
|
+
*
|
|
107
|
+
* Python is the case, and it is not an oversight that the tool seeds no Python gate: the
|
|
108
|
+
* runner genuinely varies (uv, poetry, tox, nox, a project wrapper), so a seeded gate would
|
|
109
|
+
* be wrong in the repositories it guessed about. The card can still name the one command a
|
|
110
|
+
* repository has proved: a committed `uv.lock` means uv, and the uv profile is explicit
|
|
111
|
+
* that `uv run -m pytest` is correct and `uv run pytest` is not — the console entrypoint
|
|
112
|
+
* can miss the `uv` import path and run a different copy of the package than the one under
|
|
113
|
+
* test. Without a lockfile the card names the plain runner, which is what the python
|
|
114
|
+
* profile assumes when it says "no `pytest.skip`".
|
|
115
|
+
*/
|
|
116
|
+
const CARD_FALLBACKS = [
|
|
117
|
+
{ when: (s, f) => f.has('has-uv-lock'), commands: { test: 'uv run -m pytest' } },
|
|
118
|
+
{ when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), commands: { test: 'pytest' } },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/** A non-empty string, or null. Guards JSON that is present but not a command. */
|
|
122
|
+
function text(value) {
|
|
123
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The repository's own `package.json` scripts, or an empty map. */
|
|
127
|
+
async function readScripts(root) {
|
|
128
|
+
try {
|
|
129
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
130
|
+
return pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
|
|
131
|
+
} catch {
|
|
132
|
+
// No package.json is a normal shape (a Python or Go repository), and an unreadable one
|
|
133
|
+
// is reported by the manifest path. Either way the card has no scripts to draw on.
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The command for one verb, or null when this repository proves none.
|
|
140
|
+
*
|
|
141
|
+
* Precedence is deliberate and mirrors the manifest's own. A declared `gates.commands`
|
|
142
|
+
* entry is the repository naming its own program and wins over anything inferred; a
|
|
143
|
+
* `package.json` script is the repository's declared entrypoint (`npm run <verb>`); and
|
|
144
|
+
* the ecosystem table is the canonical command for a marker that is present but whose
|
|
145
|
+
* repository has declared nothing. The last is the same table the tool seeds gates from,
|
|
146
|
+
* so the card and the gate list cannot name two different commands for one repository.
|
|
147
|
+
*/
|
|
148
|
+
function resolveVerb(verb, { manifest, facts, stack, scripts }) {
|
|
149
|
+
const declared = text(manifest?.gates?.commands?.[verb]);
|
|
150
|
+
if (declared) return declared;
|
|
151
|
+
|
|
152
|
+
// The script a verb maps to. `format` is a staged gate rather than a script gate, so its
|
|
153
|
+
// two conventional script names are tried directly — `format:check` first, because that
|
|
154
|
+
// is the one a hook and CI run, which is what the card is reporting.
|
|
155
|
+
const scriptNames = verb === 'format' ? ['format:check', 'format'] : [verb];
|
|
156
|
+
for (const name of scriptNames) {
|
|
157
|
+
if (text(scripts?.[name])) return `npm run ${name}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (verb === 'build') {
|
|
161
|
+
if (text(scripts?.build)) return 'npm run build';
|
|
162
|
+
for (const row of BUILDS) {
|
|
163
|
+
if (row.fact && facts.has(row.fact)) return row.command;
|
|
164
|
+
if (row.stack && stack.has(row.stack)) return row.command;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const eco of ECOSYSTEMS) {
|
|
170
|
+
if (!facts.has(eco.fact)) continue;
|
|
171
|
+
const command = text(eco.commands?.[verb]);
|
|
172
|
+
if (command) return command;
|
|
173
|
+
}
|
|
174
|
+
for (const row of CARD_FALLBACKS) {
|
|
175
|
+
if (!row.when(stack, facts, scripts)) continue;
|
|
176
|
+
const command = text(row.commands?.[verb]);
|
|
177
|
+
if (command) return command;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The text a set of lines renders to. Kept beside `commandCard` so both agree. */
|
|
183
|
+
function renderCard(lines) {
|
|
184
|
+
const body = lines.map((l) => `- ${l.label}: \`${l.command}\``).join('\n');
|
|
185
|
+
return `Commands for this repository:\n\n${body}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function renderBytes(lines) {
|
|
189
|
+
return lines.length === 0 ? 0 : Buffer.byteLength(renderCard(lines));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The index of the next line to drop, or -1 when nothing may be dropped.
|
|
194
|
+
*
|
|
195
|
+
* Verbs drop in `DROP_ORDER`. Focused templates drop only after every verb, from the last
|
|
196
|
+
* first, because a focused template is the plan's headline content and a repository with
|
|
197
|
+
* several is the one case where dropping one still leaves the reader a template.
|
|
198
|
+
*/
|
|
199
|
+
function pickDroppable(lines) {
|
|
200
|
+
for (const verb of DROP_ORDER) {
|
|
201
|
+
const at = lines.findIndex((l) => l.verb === verb);
|
|
202
|
+
if (at !== -1) return at;
|
|
203
|
+
}
|
|
204
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
205
|
+
if (lines[i].verb === 'focused') return i;
|
|
206
|
+
}
|
|
207
|
+
return -1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build the card for one repository.
|
|
212
|
+
*
|
|
213
|
+
* @param {{root: string, manifest?: object, stack?: string[], facts?: string[]}} input
|
|
214
|
+
* @returns {Promise<{text: string, bytes: number, lines: {label: string, command: string,
|
|
215
|
+
* verb: string}[], dropped: string[]}>}
|
|
216
|
+
*/
|
|
217
|
+
export async function commandCard({ root, manifest = null, stack = [], facts = [] }) {
|
|
218
|
+
const factSet = new Set(facts);
|
|
219
|
+
const stackSet = new Set(stack);
|
|
220
|
+
const scripts = await readScripts(root);
|
|
221
|
+
|
|
222
|
+
const lines = [];
|
|
223
|
+
for (const verb of VERBS) {
|
|
224
|
+
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts });
|
|
225
|
+
if (command) lines.push({ label: verb, command, verb });
|
|
226
|
+
}
|
|
227
|
+
for (const entry of FOCUSED) {
|
|
228
|
+
if (!entry.when(stackSet, factSet, scripts)) continue;
|
|
229
|
+
// One template per path kind: `has-uv-lock` and `has-pyproject` both match a uv repo,
|
|
230
|
+
// and two Python lines would be two answers with the second one wrong.
|
|
231
|
+
if (lines.some((l) => l.kind === entry.kind)) continue;
|
|
232
|
+
lines.push({
|
|
233
|
+
label: `focused test (${entry.kind})`,
|
|
234
|
+
command: entry.template(stackSet, factSet, scripts),
|
|
235
|
+
kind: entry.kind,
|
|
236
|
+
verb: 'focused',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Fit under the ceiling by dropping whole lines, least valuable first. Nothing is cut
|
|
241
|
+
// silently: `dropped` names every line removed so the caller can say so, because a card
|
|
242
|
+
// that quietly lost a command is a card a reader cannot tell is incomplete.
|
|
243
|
+
const dropped = [];
|
|
244
|
+
let kept = lines.slice();
|
|
245
|
+
while (renderBytes(kept) > CARD_LIMIT_BYTES) {
|
|
246
|
+
const victim = pickDroppable(kept);
|
|
247
|
+
if (victim === -1) break;
|
|
248
|
+
dropped.push(kept[victim].label);
|
|
249
|
+
kept = kept.filter((_, i) => i !== victim);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const text = kept.length > 0 ? renderCard(kept) : '';
|
|
253
|
+
return { text, bytes: Buffer.byteLength(text), lines: kept, dropped };
|
|
254
|
+
}
|