simt-dev 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +209 -7
- package/README.md +32 -5
- package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
- package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
- package/docs/decisions/ADR-003-test-breadth.md +4 -4
- package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
- package/docs/decisions/ADR-005-file-length.md +5 -5
- package/docs/decisions/ADR-006-coverage-bar.md +3 -3
- package/docs/decisions/ADR-007-section-skeleton.md +5 -5
- package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-010-runner-invocation.md +1 -1
- package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
- package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
- package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
- package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
- package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
- package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
- package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
- package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
- package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
- package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
- package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
- package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/README.md +20 -1
- package/package.json +1 -2
- package/rules/stacks/dart.md +38 -0
- package/rules/stacks/deno.md +36 -0
- package/rules/stacks/dotnet.md +35 -0
- package/rules/stacks/elixir.md +39 -0
- package/rules/stacks/go.md +34 -0
- package/rules/stacks/ruby.md +36 -0
- package/rules/stacks/rust.md +34 -0
- package/rules/stacks/swift.md +37 -0
- package/src/argv.mjs +35 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/graph.mjs +13 -4
- package/src/commands/help.mjs +22 -4
- package/src/commands/hooks.mjs +183 -67
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +156 -14
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +4 -4
- package/src/commands/sync.mjs +153 -11
- package/src/compare/discover.mjs +1 -1
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +36 -7
- package/src/decisions/index.mjs +51 -12
- package/src/graph/load.mjs +126 -15
- package/src/hooks/capability.mjs +49 -5
- package/src/hooks/dispatcher.mjs +51 -6
- package/src/hooks/gates.mjs +1 -3
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/hooks/where.mjs +1 -1
- package/src/identity.mjs +207 -0
- package/src/mcp/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/conflicts.mjs +57 -6
- package/src/projection/manifest.mjs +248 -26
- package/src/projection/markers.mjs +3 -3
- package/src/projection/mdc.mjs +6 -15
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/surfaces.mjs +2 -2
- package/src/projection/targets.mjs +53 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +4 -3
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +2 -2
- package/src/tsconfig.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/src/identity.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// The per-developer git identity, as a file the repository does not track.
|
|
2
|
+
//
|
|
3
|
+
// A repository has more than one person in it, and each of them may already have an
|
|
4
|
+
// identity this tool has no business choosing. So the rules here are conservative in
|
|
5
|
+
// that order:
|
|
6
|
+
//
|
|
7
|
+
// 1. If `git.config` already exists, it is left exactly as it is. Not updated, not
|
|
8
|
+
// merged, not reformatted. It may have been written by a person, by an older
|
|
9
|
+
// version of this tool, or by hand years ago, and none of those are this tool's
|
|
10
|
+
// to second-guess.
|
|
11
|
+
// 2. Otherwise the identity comes from the repository's own last commit author.
|
|
12
|
+
// That is the person who has been working here, which is the best available
|
|
13
|
+
// answer to "who commits here" and requires no guessing: it is a fact, not a
|
|
14
|
+
// default.
|
|
15
|
+
// 3. Otherwise from the git identity configured for this machine or this user,
|
|
16
|
+
// which is what would have been used anyway had this file never existed.
|
|
17
|
+
// 4. Otherwise nothing is written. An identity nobody configured is not one to
|
|
18
|
+
// invent, and a file with a placeholder in it is worse than no file: git would
|
|
19
|
+
// use the placeholder.
|
|
20
|
+
//
|
|
21
|
+
// The file is deliberately NOT written into the repository's tracked files. It holds
|
|
22
|
+
// whoever is committing, and committing it publishes one developer's name and email
|
|
23
|
+
// to everybody else in the repository. `init` adds it to .gitignore when it creates
|
|
24
|
+
// one; a repository that already tracks it is left that way, because removing a
|
|
25
|
+
// tracked file is not something an adopting command should do silently.
|
|
26
|
+
|
|
27
|
+
import { execFile } from 'node:child_process';
|
|
28
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
|
|
32
|
+
const exec = promisify(execFile);
|
|
33
|
+
|
|
34
|
+
/** The file, at the repository root. Named for what it is: a git config fragment. */
|
|
35
|
+
export const IDENTITY_FILE = 'git.config';
|
|
36
|
+
|
|
37
|
+
/** The git config key that pulls the file in. */
|
|
38
|
+
export const INCLUDE_KEY = 'include.path';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a git command and return trimmed stdout, or null if it fails.
|
|
42
|
+
*
|
|
43
|
+
* Null rather than an exception for every case: a repository that is not a git
|
|
44
|
+
* repository, a version of git that does not know a flag, a machine with no
|
|
45
|
+
* configured identity. None of those is an error worth refusing an adoption over.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} cwd
|
|
48
|
+
* @param {string[]} args
|
|
49
|
+
* @returns {Promise<string|null>}
|
|
50
|
+
*/
|
|
51
|
+
async function git(cwd, args) {
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await exec('git', args, { cwd });
|
|
54
|
+
return stdout.trim();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Who last committed here.
|
|
62
|
+
*
|
|
63
|
+
* `%an` and `%ae` rather than the configured identity on purpose: this is what
|
|
64
|
+
* actually happened, which is the answer to the question being asked. Reading
|
|
65
|
+
* `git config user.email` here would return the value this file is about to be
|
|
66
|
+
* derived from, and the step would be circular.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} root
|
|
69
|
+
* @returns {Promise<{name: string|null, email: string|null}>}
|
|
70
|
+
*/
|
|
71
|
+
export async function lastCommitAuthor(root) {
|
|
72
|
+
const line = await git(root, ['log', '-1', '--format=%an <%ae>']);
|
|
73
|
+
if (!line) return { name: null, email: null };
|
|
74
|
+
// The name may itself contain angle brackets, so the address is split off the
|
|
75
|
+
// LAST one rather than the first.
|
|
76
|
+
const at = line.lastIndexOf('<');
|
|
77
|
+
const close = line.lastIndexOf('>');
|
|
78
|
+
if (at === -1 || close < at) return { name: line || null, email: null };
|
|
79
|
+
return {
|
|
80
|
+
name: line.slice(0, at).trim() || null,
|
|
81
|
+
email: line.slice(at + 1, close).trim() || null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The identity this machine would use anyway.
|
|
87
|
+
*
|
|
88
|
+
* `--get` rather than `--global`: it resolves through system, global and local in
|
|
89
|
+
* git's own order, so what it returns is what a commit would have used had this file
|
|
90
|
+
* never been created. That is the fallback's whole point - it must not be a second
|
|
91
|
+
* opinion about what identity means.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} root
|
|
94
|
+
* @returns {Promise<{name: string|null, email: string|null}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function configuredIdentity(root) {
|
|
97
|
+
const read = async (key) => {
|
|
98
|
+
const value = await git(root, ['config', '--get', key]);
|
|
99
|
+
return value && value !== '' ? value : null;
|
|
100
|
+
};
|
|
101
|
+
return { name: await read('user.name'), email: await read('user.email') };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The identity to write, and where it came from.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} root
|
|
108
|
+
* @returns {Promise<{name: string|null, email: string|null, from: 'last-commit'|'configured'|null,
|
|
109
|
+
* exists: boolean, bytes: number}>}
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveIdentity(root) {
|
|
112
|
+
const path = join(root, IDENTITY_FILE);
|
|
113
|
+
try {
|
|
114
|
+
const info = await stat(path);
|
|
115
|
+
if (info.isFile()) {
|
|
116
|
+
return { name: null, email: null, from: null, exists: true, bytes: info.size };
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Absent is the case this whole module exists for.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const committed = await lastCommitAuthor(root);
|
|
123
|
+
if (committed.name && committed.email) {
|
|
124
|
+
return { ...committed, from: 'last-commit', exists: false, bytes: 0 };
|
|
125
|
+
}
|
|
126
|
+
const configured = await configuredIdentity(root);
|
|
127
|
+
if (configured.name || configured.email) {
|
|
128
|
+
return { ...configured, from: 'configured', exists: false, bytes: 0 };
|
|
129
|
+
}
|
|
130
|
+
return { name: null, email: null, from: null, exists: false, bytes: 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The file's bytes.
|
|
135
|
+
*
|
|
136
|
+
* Only the keys that were found are written. A partial identity is written as a
|
|
137
|
+
* partial identity rather than padded with a placeholder, because a placeholder is
|
|
138
|
+
* an identity git would use.
|
|
139
|
+
*
|
|
140
|
+
* @param {{name: string|null, email: string|null}} identity
|
|
141
|
+
* @returns {string} LF-terminated
|
|
142
|
+
*/
|
|
143
|
+
export function renderIdentityFile({ name, email }) {
|
|
144
|
+
const lines = ['[user]'];
|
|
145
|
+
if (name) lines.push(`\tname = ${name}`);
|
|
146
|
+
if (email) lines.push(`\temail = ${email}`);
|
|
147
|
+
return `${lines.join('\n')}\n`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether `include.path` already names this repository's own file.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} root
|
|
154
|
+
* @returns {Promise<boolean>}
|
|
155
|
+
*/
|
|
156
|
+
export async function includesOwnIdentity(root) {
|
|
157
|
+
const values = (await git(root, ['config', '--local', '--get-all', INCLUDE_KEY])) ?? '';
|
|
158
|
+
return values.split('\n').map((v) => v.trim()).filter(Boolean).includes(`../${IDENTITY_FILE}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Point `core.hooksPath`-style local config at this repository's own identity file.
|
|
163
|
+
*
|
|
164
|
+
* `--replace-all` rather than `--add`, and that is the whole point: a value naming
|
|
165
|
+
* ANOTHER repository's identity file is not an addition to keep, it is a link to
|
|
166
|
+
* break. Two of the repositories here had exactly that, and both worked only because
|
|
167
|
+
* the sibling checkout happened to exist at the expected relative path.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} root
|
|
170
|
+
* @returns {Promise<void>}
|
|
171
|
+
*/
|
|
172
|
+
export async function includeOwnIdentity(root) {
|
|
173
|
+
await git(root, ['config', '--local', '--replace-all', INCLUDE_KEY, `../${IDENTITY_FILE}`]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Add the identity file to .gitignore, if it is not already there.
|
|
178
|
+
*
|
|
179
|
+
* Matching on the exact line rather than a substring, because a `.gitignore`
|
|
180
|
+
* entry of `/git.config` or `git.config` both mean it while a comment mentioning
|
|
181
|
+
* it means nothing, and appending a duplicate of an entry that is already present
|
|
182
|
+
* is how a file ends up listed twice.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} root
|
|
185
|
+
* @returns {Promise<boolean>} whether the file was changed
|
|
186
|
+
*/
|
|
187
|
+
export async function ignoreIdentity(root) {
|
|
188
|
+
const path = join(root, '.gitignore');
|
|
189
|
+
let text;
|
|
190
|
+
try {
|
|
191
|
+
text = await readFile(path, 'utf8');
|
|
192
|
+
} catch {
|
|
193
|
+
text = '';
|
|
194
|
+
}
|
|
195
|
+
const already = text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
|
|
196
|
+
if (already) return false;
|
|
197
|
+
const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
198
|
+
const block =
|
|
199
|
+
`${prefix}\n` +
|
|
200
|
+
'# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
|
|
201
|
+
'# it carries whoever is committing into the tree, and a repository has more than\n' +
|
|
202
|
+
'# one of those.\n' +
|
|
203
|
+
`${IDENTITY_FILE}\n`;
|
|
204
|
+
const { writeFile } = await import('node:fs/promises');
|
|
205
|
+
await writeFile(path, text + block);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
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
|
+
}
|
|
@@ -93,7 +93,7 @@ indent_style = tab`;
|
|
|
93
93
|
* every path as text and is the single most common way to corrupt a binary in a
|
|
94
94
|
* repository, because nothing tells git it is wrong. `auto` makes git decide per path
|
|
95
95
|
* from its own content sniffing, so a `.tif`, a `.zip` and a `.mc` file are left exactly
|
|
96
|
-
* as they are and only files that genuinely are text get normalised.
|
|
96
|
+
* as they are and only files that genuinely are text get normalised. One
|
|
97
97
|
* already worked this out on its own, which is the strongest evidence available that
|
|
98
98
|
* the conservative form is the one the estate would have picked.
|
|
99
99
|
*
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
// the same rule restated in prose outside a generated region - prohibited by the
|
|
7
7
|
// anti-duplication doctrine, but not a contradiction.
|
|
8
8
|
//
|
|
9
|
-
// The distinction is not academic.
|
|
9
|
+
// The distinction is not academic. One AGENTS.md says "Do not use `uv run
|
|
10
10
|
// pytest`" - which contains the forbidden string `uv run pytest` verbatim. A naive
|
|
11
11
|
// substring scan reports that file as violating the very rule it is correctly
|
|
12
12
|
// restating. So every match is checked for prohibition context first.
|
|
13
13
|
|
|
14
|
+
import { POINTERS, SURFACES } from './surfaces.mjs';
|
|
15
|
+
|
|
14
16
|
/** Files whose text is a hand-written rule surface. Scanned for conflicts only;
|
|
15
17
|
* generated regions are excluded at read time by the caller.
|
|
16
18
|
*
|
|
@@ -18,11 +20,60 @@
|
|
|
18
20
|
* worth recording: the estate's three always-on `.mdc` families were the single
|
|
19
21
|
* worst-drifted content in it, and `simt-dev init` reported `CONFLICTS none detected`
|
|
20
22
|
* on the repo holding them. A conflict detector that only looks at the filenames
|
|
21
|
-
* it was written for will quietly miss a whole rule surface added later.
|
|
23
|
+
* it was written for will quietly miss a whole rule surface added later.
|
|
24
|
+
*
|
|
25
|
+
* And then it did. The comment above was written as a lesson and the lesson was not
|
|
26
|
+
* taken: the list stayed hand-written, two surfaces were added to `SURFACES`
|
|
27
|
+
* afterwards, and `.claude/rules/` and `.agents/rules/` were silently unscanned —
|
|
28
|
+
* two of the five surfaces, and both of them read a rule that this tool writes. A
|
|
29
|
+
* comment saying "derive this" is not deriving it. So the per-rule directories come
|
|
30
|
+
* from `SURFACES` now (see `ruleGlobs`), and this list holds only the files no
|
|
31
|
+
* surface declares. */
|
|
22
32
|
export const RULE_FILES = ['AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md'];
|
|
23
33
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
34
|
+
/** Pointer targets, scanned because this tool writes them and they are rule files.
|
|
35
|
+
*
|
|
36
|
+
* `CLAUDE.md` was named by hand in `RULE_FILES` for the whole life of the tool, and
|
|
37
|
+
* `GEMINI.md` — which `POINTERS` creates for a repo on the Antigravity surface — was
|
|
38
|
+
* not. So one file this tool writes was scanned and the other was not, with nothing
|
|
39
|
+
* in the source saying which was which: the difference was an accident of the order
|
|
40
|
+
* the two were added. Derived from `POINTERS` now, so it cannot recur. */
|
|
41
|
+
function pointerFiles() {
|
|
42
|
+
return POINTERS.map((p) => p.path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The per-rule directories, read out of `SURFACES` rather than written out here.
|
|
47
|
+
*
|
|
48
|
+
* One list of surfaces, read by everything that has to know where a surface keeps its
|
|
49
|
+
* rules. A surface added to `SURFACES` is now scanned for conflicts the moment it
|
|
50
|
+
* exists, which is the property the hand-written list kept failing to have — and it
|
|
51
|
+
* could only fail quietly, because a detector that matches nothing reports the same
|
|
52
|
+
* answer as a repository with no rules.
|
|
53
|
+
*
|
|
54
|
+
* `CLAUDE.md` and `GEMINI.md` are here through `RULE_FILES`/`POINTERS` rather than
|
|
55
|
+
* through this: they are symlinks to the primary region, so they are scanned as
|
|
56
|
+
* *files*, and `CONTRIBUTING.md` belongs to no surface at all.
|
|
57
|
+
*
|
|
58
|
+
* Takes the list as an argument rather than closing over it so the derivation can be
|
|
59
|
+
* shown on a surface that does not exist. The default is `SURFACES`, so every caller
|
|
60
|
+
* still reads the one authority; there is no way to pass anything else in production.
|
|
61
|
+
*
|
|
62
|
+
* @param {{rule?: string}[]} [surfaces]
|
|
63
|
+
*/
|
|
64
|
+
export function ruleGlobs(surfaces = SURFACES) {
|
|
65
|
+
return surfaces.filter((s) => typeof s.rule === 'string').map((s) => {
|
|
66
|
+
const slash = s.rule.lastIndexOf('/');
|
|
67
|
+
const extension = s.rule.slice(s.rule.lastIndexOf('.'));
|
|
68
|
+
// The FINAL extension, not the whole tail after `{name}`. Copilot's template is
|
|
69
|
+
// `{name}.instructions.md`, and matching that whole suffix would be narrower than
|
|
70
|
+
// the `.md` this file used to list by hand — which is the difference between seeing
|
|
71
|
+
// a hand-written `reviewer.md` in that directory and reporting the directory as
|
|
72
|
+
// empty. A file this tool writes always carries its own suffix; a file a person
|
|
73
|
+
// wrote in the same directory need not, and the person is who this scanner is for.
|
|
74
|
+
return `${s.rule.slice(0, slash)}/*${extension}`;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
26
77
|
|
|
27
78
|
/** Every hand-written rule path in a repo, named or globbed.
|
|
28
79
|
*
|
|
@@ -36,8 +87,8 @@ export const RULE_GLOBS = ['.cursor/rules/*.mdc', '.github/instructions/*.md'];
|
|
|
36
87
|
export async function rulePaths(root) {
|
|
37
88
|
const { readdir } = await import('node:fs/promises');
|
|
38
89
|
const { join } = await import('node:path');
|
|
39
|
-
const out = RULE_FILES.map((f) => join(root, f));
|
|
40
|
-
for (const glob of
|
|
90
|
+
const out = [...new Set([...RULE_FILES, ...pointerFiles()])].map((f) => join(root, f));
|
|
91
|
+
for (const glob of ruleGlobs()) {
|
|
41
92
|
const star = glob.lastIndexOf('*');
|
|
42
93
|
const dir = join(root, glob.slice(0, star));
|
|
43
94
|
const tail = glob.slice(star + 1);
|