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.
Files changed (91) hide show
  1. package/CHANGELOG.md +209 -7
  2. package/README.md +32 -5
  3. package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
  4. package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
  5. package/docs/decisions/ADR-003-test-breadth.md +4 -4
  6. package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
  7. package/docs/decisions/ADR-005-file-length.md +5 -5
  8. package/docs/decisions/ADR-006-coverage-bar.md +3 -3
  9. package/docs/decisions/ADR-007-section-skeleton.md +5 -5
  10. package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
  11. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  12. package/docs/decisions/ADR-010-runner-invocation.md +1 -1
  13. package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
  14. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
  15. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
  16. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
  17. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
  18. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
  19. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
  20. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
  21. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
  22. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  23. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
  24. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
  25. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
  26. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
  27. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
  28. package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
  29. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
  30. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
  31. package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  32. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  33. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  34. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  35. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  36. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  37. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  38. package/docs/decisions/README.md +20 -1
  39. package/package.json +1 -2
  40. package/rules/stacks/dart.md +38 -0
  41. package/rules/stacks/deno.md +36 -0
  42. package/rules/stacks/dotnet.md +35 -0
  43. package/rules/stacks/elixir.md +39 -0
  44. package/rules/stacks/go.md +34 -0
  45. package/rules/stacks/ruby.md +36 -0
  46. package/rules/stacks/rust.md +34 -0
  47. package/rules/stacks/swift.md +37 -0
  48. package/src/argv.mjs +35 -6
  49. package/src/ci/dependabot.mjs +1 -1
  50. package/src/ci/workflow.mjs +33 -11
  51. package/src/commands/graph.mjs +13 -4
  52. package/src/commands/help.mjs +22 -4
  53. package/src/commands/hooks.mjs +183 -67
  54. package/src/commands/index.mjs +53 -45
  55. package/src/commands/init.mjs +156 -14
  56. package/src/commands/rules.mjs +54 -3
  57. package/src/commands/skills.mjs +143 -15
  58. package/src/commands/skillsinstall.mjs +4 -4
  59. package/src/commands/sync.mjs +153 -11
  60. package/src/compare/discover.mjs +1 -1
  61. package/src/compare/hooksstate.mjs +96 -9
  62. package/src/compare/row.mjs +36 -7
  63. package/src/decisions/index.mjs +51 -12
  64. package/src/graph/load.mjs +126 -15
  65. package/src/hooks/capability.mjs +49 -5
  66. package/src/hooks/dispatcher.mjs +51 -6
  67. package/src/hooks/gates.mjs +1 -3
  68. package/src/hooks/resolve.mjs +24 -25
  69. package/src/hooks/shim.mjs +29 -5
  70. package/src/hooks/where.mjs +1 -1
  71. package/src/identity.mjs +207 -0
  72. package/src/mcp/tools.mjs +2 -2
  73. package/src/output/index.mjs +8 -7
  74. package/src/package-version.mjs +74 -0
  75. package/src/projection/baselines.mjs +1 -1
  76. package/src/projection/conflicts.mjs +57 -6
  77. package/src/projection/manifest.mjs +248 -26
  78. package/src/projection/markers.mjs +3 -3
  79. package/src/projection/mdc.mjs +6 -15
  80. package/src/projection/project.mjs +1 -1
  81. package/src/projection/rules.mjs +21 -8
  82. package/src/projection/surfaces.mjs +2 -2
  83. package/src/projection/targets.mjs +53 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +4 -3
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +2 -2
  89. package/src/tsconfig.mjs +1 -1
  90. package/src/workspace.mjs +0 -9
  91. package/graph/skills.graph.json +0 -1622
@@ -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, REGISTRY_PATH } from '../graph/load.mjs';
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(REGISTRY_PATH, args ?? {});
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`,
@@ -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-dev.json missing or invalid
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
- [EXIT.USAGE]: 'run: simt-dev --help',
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] ?? 'run: simt-dev --help';
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. simt-garmin
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. bunnyland's AGENTS.md says "Do not use `uv run
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
- /** Rule files discovered by glob rather than by name. */
25
- export const RULE_GLOBS = ['.cursor/rules/*.mdc', '.github/instructions/*.md'];
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 RULE_GLOBS) {
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);