simt-dev 0.2.0__py3-none-any.whl

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 (138) hide show
  1. python/simt_dev/__init__.py +32 -0
  2. python/simt_dev/cli.py +225 -0
  3. simt_dev/__init__.py +32 -0
  4. simt_dev/cli.py +225 -0
  5. simt_dev/vendored/CHANGELOG.md +166 -0
  6. simt_dev/vendored/bin/simt-dev.mjs +28 -0
  7. simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  8. simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  9. simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
  10. simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  11. simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
  12. simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
  13. simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
  14. simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  15. simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
  16. simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
  17. simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
  18. simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  19. simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  20. simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  21. simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  22. simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  23. simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  24. simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  25. simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  26. simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  27. simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  28. simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  29. simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  30. simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  31. simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  32. simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  33. simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  34. simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  35. simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  36. simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  37. simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  38. simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  39. simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  40. simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  41. simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  42. simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  43. simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  44. simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  45. simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  46. simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  47. simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  48. simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  49. simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  50. simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  51. simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  52. simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  53. simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  54. simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  55. simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  56. simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  57. simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  58. simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  59. simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  60. simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  61. simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  62. simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  63. simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  64. simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  65. simt_dev/vendored/docs/decisions/README.md +530 -0
  66. simt_dev/vendored/graph/skills.graph.json +1622 -0
  67. simt_dev/vendored/package.json +43 -0
  68. simt_dev/vendored/rules/core.md +76 -0
  69. simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
  70. simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
  71. simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
  72. simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
  73. simt_dev/vendored/rules/stacks/android.md +63 -0
  74. simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
  75. simt_dev/vendored/rules/stacks/connectiq.md +62 -0
  76. simt_dev/vendored/rules/stacks/container.md +31 -0
  77. simt_dev/vendored/rules/stacks/jvm.md +54 -0
  78. simt_dev/vendored/rules/stacks/node.md +45 -0
  79. simt_dev/vendored/rules/stacks/python.md +48 -0
  80. simt_dev/vendored/rules/stacks/react-native.md +58 -0
  81. simt_dev/vendored/rules/stacks/react-web.md +61 -0
  82. simt_dev/vendored/rules/stacks/typescript.md +21 -0
  83. simt_dev/vendored/rules/stacks/uv.md +32 -0
  84. simt_dev/vendored/src/argv.mjs +116 -0
  85. simt_dev/vendored/src/ci/dependabot.mjs +268 -0
  86. simt_dev/vendored/src/ci/workflow.mjs +360 -0
  87. simt_dev/vendored/src/commands/ci.mjs +285 -0
  88. simt_dev/vendored/src/commands/compare.mjs +195 -0
  89. simt_dev/vendored/src/commands/graph.mjs +73 -0
  90. simt_dev/vendored/src/commands/help.mjs +108 -0
  91. simt_dev/vendored/src/commands/hooks.mjs +471 -0
  92. simt_dev/vendored/src/commands/index.mjs +241 -0
  93. simt_dev/vendored/src/commands/init.mjs +632 -0
  94. simt_dev/vendored/src/commands/mcp.mjs +63 -0
  95. simt_dev/vendored/src/commands/rules.mjs +374 -0
  96. simt_dev/vendored/src/commands/skills.mjs +647 -0
  97. simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
  98. simt_dev/vendored/src/commands/sync.mjs +331 -0
  99. simt_dev/vendored/src/commands/upgrade.mjs +202 -0
  100. simt_dev/vendored/src/compare/discover.mjs +212 -0
  101. simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
  102. simt_dev/vendored/src/compare/row.mjs +299 -0
  103. simt_dev/vendored/src/decisions/index.mjs +536 -0
  104. simt_dev/vendored/src/graph/load.mjs +541 -0
  105. simt_dev/vendored/src/hooks/capability.mjs +129 -0
  106. simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
  107. simt_dev/vendored/src/hooks/gates.mjs +144 -0
  108. simt_dev/vendored/src/hooks/resolve.mjs +194 -0
  109. simt_dev/vendored/src/hooks/shim.mjs +140 -0
  110. simt_dev/vendored/src/hooks/where.mjs +118 -0
  111. simt_dev/vendored/src/identity.mjs +218 -0
  112. simt_dev/vendored/src/locate.mjs +75 -0
  113. simt_dev/vendored/src/mcp/server.mjs +167 -0
  114. simt_dev/vendored/src/mcp/tools.mjs +410 -0
  115. simt_dev/vendored/src/name.mjs +95 -0
  116. simt_dev/vendored/src/output/index.mjs +96 -0
  117. simt_dev/vendored/src/projection/baselines.mjs +209 -0
  118. simt_dev/vendored/src/projection/conflicts.mjs +205 -0
  119. simt_dev/vendored/src/projection/manifest.mjs +478 -0
  120. simt_dev/vendored/src/projection/markers.mjs +349 -0
  121. simt_dev/vendored/src/projection/mdc.mjs +327 -0
  122. simt_dev/vendored/src/projection/project.mjs +504 -0
  123. simt_dev/vendored/src/projection/rules.mjs +640 -0
  124. simt_dev/vendored/src/projection/surfaces.mjs +398 -0
  125. simt_dev/vendored/src/projection/targets.mjs +221 -0
  126. simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
  127. simt_dev/vendored/src/resolve/near.mjs +61 -0
  128. simt_dev/vendored/src/resolve/resolve.mjs +457 -0
  129. simt_dev/vendored/src/resolve/source.mjs +105 -0
  130. simt_dev/vendored/src/toolchain.mjs +202 -0
  131. simt_dev/vendored/src/tsconfig.mjs +314 -0
  132. simt_dev/vendored/src/upgrade/plan.mjs +203 -0
  133. simt_dev/vendored/src/workspace.mjs +289 -0
  134. simt_dev-0.2.0.dist-info/METADATA +150 -0
  135. simt_dev-0.2.0.dist-info/RECORD +138 -0
  136. simt_dev-0.2.0.dist-info/WHEEL +4 -0
  137. simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
  138. simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,212 @@
1
+ // Find the sibling repositories a `simt-dev compare` should report on.
2
+ //
3
+ // Three things here are load-bearing, and each exists because the alternative is
4
+ // a wrong answer rather than a missing one:
5
+ //
6
+ // 1. `.git` may be a DIRECTORY (a normal repo) or a FILE (a linked worktree).
7
+ // stat-following the directory scan alone misses every worktree, and the
8
+ // estate has one. A tool that silently omits a repo is worse than one that
9
+ // lists it as a repo, because an agent reads absence as "nothing there".
10
+ // 2. A git worktree is not an independent consumer. It shares another repo's
11
+ // object store and its `.git` file points at that repo's `worktrees/` entry,
12
+ // so it must be attributed to its parent or a projection drift gets reported
13
+ // against the wrong directory.
14
+ // 3. Directories that are not repos are not candidates, but they are still
15
+ // COUNTED. A directory of 25 PNG screenshots is not a rule source; silently dropping it
16
+ // hides the fact that a directory in the workspace is unmanaged, which is a
17
+ // real finding during a migration.
18
+ //
19
+ // Nothing here assumes a workspace layout, a repo count, or that simt-dev's
20
+ // siblings are git repos at all (ADR-013: the tool must be useful outside SIMT).
21
+
22
+ import { readdir, readFile, stat } from 'node:fs/promises';
23
+ import { basename, join, resolve } from 'node:path';
24
+
25
+ /** `@param {string} p` @returns {Promise<boolean>} true when `p` is a directory. */
26
+ async function isDir(p) {
27
+ try {
28
+ return (await stat(p)).isDirectory();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /** `@param {string} p` @returns {Promise<string|null>} file contents, or null. */
35
+ async function readIfFile(p) {
36
+ try {
37
+ return await readFile(p, 'utf8');
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The raw `gitdir:` pointer from a `.git` file, or null when the file is not one.
45
+ *
46
+ * The pointer is NOT resolved here: a relative pointer is relative to the
47
+ * worktree, not to the process cwd, and only the caller knows the worktree.
48
+ */
49
+ export function parseGitdirPointer(text) {
50
+ if (typeof text !== 'string') return null;
51
+ const match = /^gitdir:\s*(.+)$/m.exec(text.trim());
52
+ return match ? match[1].trim() : null;
53
+ }
54
+
55
+ /**
56
+ * Split a resolved `gitdir` path into the repo that owns the worktree and the
57
+ * worktree's own slug, or null when the path is not a worktree entry.
58
+ *
59
+ * `<repo>/.git/worktrees/<slug>` - so `<slug>` is the worktree directory name with
60
+ * its leading dot dropped and `/` turned into `-`: `.wt-wear-redesign` becomes
61
+ * `-wt-wear-redesign`.
62
+ */
63
+ export function ownerFromGitdir(gitdir) {
64
+ if (!gitdir) return null;
65
+ // The leading slash is significant: splitting on '/' and dropping empty parts
66
+ // would turn `/a/b/.git/...` into `a/b`, a path that then resolves against the
67
+ // cwd instead of the filesystem root. Absolute stays absolute.
68
+ const absolute = gitdir.startsWith('/');
69
+ const parts = gitdir.split('/').filter(Boolean);
70
+ const at = parts.lastIndexOf('worktrees');
71
+ if (at < 2) return null;
72
+ const owner = parts.slice(0, at - 1).join('/');
73
+ if (owner === '') return null;
74
+ return { ownerDir: absolute ? `/${owner}` : owner, slug: parts[at + 1] ?? '' };
75
+ }
76
+
77
+ /**
78
+ * Compare a worktree's directory name against its git slug.
79
+ *
80
+ * The slug loses the leading dot, so `.wt-wear-redesign` -> `-wt-wear-redesign`.
81
+ * A mismatch means the directory was renamed after `git worktree add` and the
82
+ * slug can no longer be trusted to identify which repo owns it - which is exactly
83
+ * when reporting the wrong owner would be most damaging, because the finding
84
+ * would be attributed to an innocent repo.
85
+ */
86
+ export function slugMatchesDir(slug, dirName) {
87
+ if (!slug || !dirName) return false;
88
+ const normalise = (s) => s.replace(/^[.-]+/, '');
89
+ return normalise(slug) === normalise(dirName);
90
+ }
91
+
92
+ /**
93
+ * Classify one candidate directory.
94
+ * @returns {Promise<{kind:'repo'|'worktree'|'directory'|'unreadable', gitdir?:string,
95
+ * owner?:string, ownerMatches?:boolean, slug?:string}>}
96
+ */
97
+ export async function classify(dir) {
98
+ const git = join(dir, '.git');
99
+ const info = await stat(git).catch(() => null);
100
+
101
+ if (info === null) {
102
+ // No .git at all. It may still be a readable directory (an unmanaged
103
+ // workspace member) or not readable at all; either way it is not a repo.
104
+ return { kind: (await isDir(dir)) ? 'directory' : 'unreadable' };
105
+ }
106
+ if (info.isDirectory()) return { kind: 'repo' };
107
+
108
+ const text = await readIfFile(git);
109
+ const raw = parseGitdirPointer(text);
110
+ if (!raw) {
111
+ // A .git file that is not a gitdir pointer is something else entirely, and
112
+ // treating it as a repo would make every later command fail confusingly.
113
+ return { kind: 'directory' };
114
+ }
115
+
116
+ // A relative pointer is relative to the worktree, not to the cwd.
117
+ const gitdir = raw.startsWith('/') ? raw : resolve(dir, raw);
118
+ const owner = ownerFromGitdir(gitdir);
119
+ const dirName = basename(dir);
120
+ return {
121
+ kind: 'worktree',
122
+ gitdir,
123
+ owner: owner?.ownerDir ?? null,
124
+ ownerName: owner ? basename(owner.ownerDir) : null,
125
+ ownerMatches: owner ? slugMatchesDir(owner.slug, dirName) : false,
126
+ slug: owner?.slug ?? null,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Scan `root` and classify what is there.
132
+ *
133
+ * Two shapes are both valid, and which one you have is a FACT of the filesystem rather
134
+ * than something the caller has to configure (ADR-014):
135
+ *
136
+ * - `root` IS a repository -> it is the single repo to report on. This is the
137
+ * common case outside a multi-repo workspace, and treating it as an error
138
+ * ("contains no git repositories") would mean the tool only works for people
139
+ * who happen to keep several repos together.
140
+ * - `root` CONTAINS repositories -> its immediate children are the estate.
141
+ *
142
+ * Only the second shape scans one level deep, on purpose. The estate is flat
143
+ * siblings; a recursive walk would descend into node_modules and every build output,
144
+ * which is unbounded work for a dashboard. A monorepo names its members explicitly
145
+ * (ADR ruling #2).
146
+ *
147
+ * @returns {Promise<{root:string, shape:'repo'|'workspace', repos:string[],
148
+ * worktrees:object[], directories:string[], error:string|null}>}
149
+ */
150
+ export async function discover(root) {
151
+ const self = await classify(root);
152
+ if (self.kind === 'repo') {
153
+ return { root, shape: 'repo', repos: [root], worktrees: [], directories: [], error: null };
154
+ }
155
+ if (self.kind === 'worktree') {
156
+ // A worktree passed directly is attributed, never counted: its files are its
157
+ // parent's HEAD checked out elsewhere, so reporting it as a consumer would
158
+ // double-count one repo's drift.
159
+ return {
160
+ root,
161
+ shape: 'repo',
162
+ repos: [],
163
+ worktrees: [{ path: root, name: basename(root), ...self }],
164
+ directories: [],
165
+ error: null,
166
+ };
167
+ }
168
+
169
+ let entries;
170
+ try {
171
+ entries = await readdir(root, { withFileTypes: true });
172
+ } catch (error) {
173
+ return {
174
+ root,
175
+ shape: 'workspace',
176
+ repos: [],
177
+ worktrees: [],
178
+ directories: [],
179
+ error: error.code ?? String(error),
180
+ };
181
+ }
182
+
183
+ const repos = [];
184
+ const worktrees = [];
185
+ const directories = [];
186
+
187
+ for (const entry of entries) {
188
+ // A dotfile is never a consumer: `.wt-wear-redesign` is caught as a worktree
189
+ // below, and skipping dotdirs here only removes editor and VCS noise.
190
+ if (entry.name.startsWith('.') && !entry.name.startsWith('.wt')) continue;
191
+ if (!entry.isDirectory()) continue;
192
+
193
+ const path = join(root, entry.name);
194
+ const kind = await classify(path);
195
+ if (kind.kind === 'repo') repos.push(path);
196
+ else if (kind.kind === 'worktree') worktrees.push({ path, name: entry.name, ...kind });
197
+ else if (kind.kind === 'directory') directories.push(path);
198
+ }
199
+
200
+ // A stable order makes two runs diffable, which is the whole point of a
201
+ // dashboard: an agent comparing yesterday's output to today's must not be
202
+ // reading reordering as change.
203
+ const byName = (a, b) => basename(a).localeCompare(basename(b));
204
+ return {
205
+ root,
206
+ shape: 'workspace',
207
+ repos: repos.sort(byName),
208
+ worktrees: worktrees.sort((a, b) => a.name.localeCompare(b.name)),
209
+ directories: directories.sort(byName),
210
+ error: null,
211
+ };
212
+ }
@@ -0,0 +1,159 @@
1
+ // Read a repo's hook routing and shim state without changing anything.
2
+ //
3
+ // This is the read half of what `simt-dev hooks status` does inline. It is a separate
4
+ // module because two callers need it - `hooks status` and `simt-dev compare` - and a
5
+ // second implementation of "is this shim current?" would be exactly the
6
+ // duplicated-source-of-truth problem the tool exists to remove. `compare` reads it
7
+ // for N repos, so it must not shell out per repo per event.
8
+
9
+ import { execFile } from 'node:child_process';
10
+ import { readFile, stat } from 'node:fs/promises';
11
+ import { basename, join } from 'node:path';
12
+ import { promisify } from 'node:util';
13
+
14
+ import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderShim } from '../hooks/shim.mjs';
15
+ import { foreignHooks, resolveHooksDir } from '../hooks/where.mjs';
16
+
17
+ const exec = promisify(execFile);
18
+
19
+ /**
20
+ * The chained shim at `path`, read back out of the file itself.
21
+ *
22
+ * Content, for the reason given on `CHAIN_MARKER`: the file is named after the
23
+ * repository's hook, so the name is exactly the thing that cannot identify it.
24
+ *
25
+ * The target is parsed out of the file rather than remembered from install time
26
+ * because the only authority on what a shim does is the shim. A re-install has to
27
+ * render the identical bytes, and rendering them from a remembered target would let
28
+ * the two drift the moment a repository moved.
29
+ *
30
+ * @param {string} path
31
+ * @returns {Promise<{event:string, target:string}|null>}
32
+ */
33
+ export async function readChainedShim(path) {
34
+ let content;
35
+ try {
36
+ content = await readFile(path, 'utf8');
37
+ } catch {
38
+ return null;
39
+ }
40
+ if (!content.includes(CHAIN_MARKER)) return null;
41
+ const target = /^TARGET='(.+)'$/m.exec(content);
42
+ const event = /^# simt-dev does not gate (\S+),/m.exec(content);
43
+ if (!target || !event) return null;
44
+ return { event: event[1], target: target[1] };
45
+ }
46
+
47
+ /** Whether this file is a chained shim this tool wrote. */
48
+ async function isChainedShim(path) {
49
+ return (await readChainedShim(path)) !== null;
50
+ }
51
+
52
+ /** `core.hooksPath`, or null when unset or the repo is not a git repo. */
53
+ export async function readHooksPath(root) {
54
+ try {
55
+ const { stdout } = await exec('git', ['config', '--get', HOOKS_PATH_KEY], { cwd: root });
56
+ const value = stdout.trim();
57
+ return value === '' ? null : value;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * One shim's state. A shim that differs from what this simt-dev generates is
65
+ * `stale`: the gates it enforces are from another version, so it cannot be
66
+ * assumed to know about a gate added since.
67
+ */
68
+ export async function describeShim(path) {
69
+ let content;
70
+ try {
71
+ content = await readFile(path, 'utf8');
72
+ } catch {
73
+ return { present: false, executable: false, stale: false };
74
+ }
75
+ let info;
76
+ try {
77
+ info = await stat(path);
78
+ } catch {
79
+ return { present: false, executable: false, stale: false };
80
+ }
81
+ let stale = true;
82
+ try {
83
+ stale = content !== renderShim(basename(path));
84
+ } catch {
85
+ stale = true;
86
+ }
87
+ return { present: true, executable: (info.mode & 0o111) !== 0, stale };
88
+ }
89
+
90
+ /**
91
+ * @param {string} root
92
+ * @param {{declaresGate?: (event:string)=>boolean}} [options] a missing shim is
93
+ * only a problem when the repo has a gate that shim would enforce.
94
+ * @returns {Promise<{current:string|null, routed:boolean, complete:boolean,
95
+ * installed:object[], foreign:object[], liveDir:string|null,
96
+ * problems:string[]}>}
97
+ */
98
+ export async function readHooksState(root, options = {}) {
99
+ const declaresGate = options.declaresGate ?? (() => true);
100
+ const current = await readHooksPath(root);
101
+ const installed = [];
102
+ for (const event of EVENTS) {
103
+ installed.push({ event, ...(await describeShim(join(root, fileFor(event)))) });
104
+ }
105
+
106
+ // What git runs right now, which is not the same question as "is a simt-dev
107
+ // shim installed". Before this existed the tool read an unset core.hooksPath
108
+ // as "no hooks" and reported a live hand-written hook as not installed.
109
+ const where = await resolveHooksDir(root);
110
+ const live = where.how === 'not-a-repo' ? [] : await foreignHooks(root);
111
+ // A shim is also called `pre-commit`, so the name cannot tell ours from a
112
+ // hand-written one. Only the bytes can.
113
+ const foreign = [];
114
+ const chained = [];
115
+ for (const hook of live) {
116
+ if (EVENTS.includes(hook.event) && !(await describeShim(hook.path)).stale) continue;
117
+ // A chained shim is ours too, and its name says nothing: `post-commit` is what the
118
+ // repository called its own hook, and the shim exists under that name precisely so
119
+ // git finds it. Reading it as foreign would make every re-install refuse with "this
120
+ // repo already runs hooks simt did not write" - a shim this tool wrote, refusing
121
+ // itself. Identity is content, the same rule as the dispatcher shim above.
122
+ const chain = await readChainedShim(hook.path);
123
+ if (chain) {
124
+ chained.push({ ...chain, path: hook.path });
125
+ continue;
126
+ }
127
+ foreign.push(hook);
128
+ }
129
+
130
+ const problems = [];
131
+ for (const i of installed) {
132
+ if (!declaresGate(i.event)) continue;
133
+ if (!i.present) problems.push(`${i.event} not installed`);
134
+ else if (!i.executable) problems.push(`${i.event} is not executable`);
135
+ else if (i.stale) problems.push(`${i.event} differs from this simt-dev; reinstall`);
136
+ }
137
+ for (const f of foreign) {
138
+ problems.push(`git runs ${f.event} at ${f.path} (${f.bytes} bytes), which is not a simt-dev shim`);
139
+ }
140
+
141
+ return {
142
+ current,
143
+ routed: current === HOOKS_DIR,
144
+ // Complete means routed AND every shim present, executable and current. A repo
145
+ // can route correctly while a shim is missing, which looks installed and is not.
146
+ complete: current === HOOKS_DIR && problems.length === 0,
147
+ installed,
148
+ foreign,
149
+ // The chained shims this repo is already running, read back off disk. `install`
150
+ // re-renders them from here so a second run keeps maintaining them rather than
151
+ // quietly forgetting them; without this field the set of things install maintains
152
+ // would depend on whether the operator happened to run it twice.
153
+ chained,
154
+ liveDir: where.dir,
155
+ problems,
156
+ };
157
+ }
158
+
159
+ export { HOOKS_DIR, HOOKS_PATH_KEY };
@@ -0,0 +1,299 @@
1
+ // The matrix a `simt-dev compare` row is built from: one repo's adoption state.
2
+ //
3
+ // The shape of a row answers one question - "would this repo behave correctly if
4
+ // an agent followed it today?" - from the four things that can make it not:
5
+ //
6
+ // manifest is the repo in the ecosystem at all?
7
+ // region does the committed projection match this simt-dev?
8
+ // hooks would a commit actually be gated?
9
+ // gates do the declared gates exist, and can they run?
10
+ //
11
+ // A repo can be adopted and still wrong (drifted region, missing hooks), which is
12
+ // the case a hand audit misses: the repo LOOKS done. That is why these are four
13
+ // columns and not one status.
14
+ //
15
+ // Ordering within a row is fixed. Output design (plan 0002 section 6) requires one
16
+ // fact per line in a stable field order so two runs diff cleanly; a row that
17
+ // reordered its columns would make every comparison between two runs unreadable.
18
+
19
+ import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
20
+ import { readManifest } from '../projection/manifest.mjs';
21
+ import { loadRules, select } from '../projection/rules.mjs';
22
+ import { knownGates } from '../hooks/resolve.mjs';
23
+ import { fingerprint } from '../resolve/fingerprint.mjs';
24
+ import { readHooksState } from './hooksstate.mjs';
25
+ import { CLI } from '../name.mjs';
26
+ import { MANIFEST_FILE } from '../name.mjs';
27
+
28
+ /**
29
+ * @typedef {object} RepoRow
30
+ * @property {string} name directory name, the thing a human reads
31
+ * @property {string} path
32
+ * @property {'adopted'|'unmanaged'|'broken'} adoption
33
+ * @property {string|null} id manifest id, when declared
34
+ * @property {string[]} stack
35
+ * @property {string[]} facts
36
+ * @property {string[]} profiles profile ids that would be selected
37
+ * @property {string|null} pinnedSha
38
+ * @property {string|null} recordedSha
39
+ * @property {'current'|'drift'|'unmanaged'|'absent'|'malformed'} region
40
+ * @property {string|null} regionDetail
41
+ * @property {boolean} hooksRouted core.hooksPath points at the shared dispatcher
42
+ * @property {boolean} hooksComplete every event installed, executable, not stale
43
+ * @property {string[]} gatesDeclared
44
+ * @property {string[]} gateProblems
45
+ * @property {string[]} problems everything wrong, one line each; drives the exit code
46
+ * @property {string[]} notes worth knowing, but not wrong; does NOT drive the exit code
47
+ */
48
+
49
+ /**
50
+ * Build one row. Never throws: a repo that cannot be read becomes a row with a
51
+ * problem, because a dashboard that drops unreadable repos reports a smaller
52
+ * estate than exists and the reader has no way to know.
53
+ *
54
+ * @param {string} path
55
+ * @param {{expectedSha: string, gates: Set<string>}} context
56
+ * @returns {Promise<RepoRow>}
57
+ */
58
+ export async function buildRow(path, context) {
59
+ const name = path.split('/').filter(Boolean).pop() ?? path;
60
+ const problems = [];
61
+ const notes = [];
62
+
63
+ const [manifest, fp, hooks, findings] = await Promise.all([
64
+ readManifest(path),
65
+ fingerprint(path),
66
+ readHooksState(path),
67
+ checkTargets(path),
68
+ ]);
69
+
70
+ // The row's `region` stays the AGENTS.md finding, because that is what a reader
71
+ // recognises. The rule files and pointers are summarised beside it, because a repo
72
+ // can have a current AGENTS.md and three stale rule files - which is exactly the
73
+ // state a rule written to four surfaces can drift into.
74
+ const region = findings.find((f) => f.file === PRIMARY_TARGET) ?? { status: 'absent' };
75
+ const ruleFindings = findings.filter((f) => f.surface !== undefined);
76
+ const pointers = findings.filter((f) => f.status?.startsWith('pointer'));
77
+
78
+ // Adoption is settled BEFORE any problem is collected. Every gate below asks
79
+ // `row.adoption`, and this block used to run after them - so the stale-rule-file gate
80
+ // below compared against the initialiser's `'unmanaged'` and could never fire. In the
81
+ // estate, with 0 of 3 rule files current, report a problems list that mentioned
82
+ // neither the rule files nor anything the exit code could see. A gate
83
+ // that reads a field the line above has not set yet is not a gate.
84
+ //
85
+ // An absent manifest is not a defect; it is the pre-adoption state, which is the normal
86
+ // condition of most repos on day one of a migration. It is counted and reported, never
87
+ // called a problem.
88
+ const adoption = !manifest.exists ? 'unmanaged' : manifest.error ? 'broken' : 'adopted';
89
+
90
+ // The profile ids this repo's signals select. These are the two calls `desiredRegion`
91
+ // makes, on the same inputs it uses, so a dashboard cannot disagree with what `sync`
92
+ // would render. `test/compare.test.mjs` asserts the two agree rather than trusting this
93
+ // comment: the field sat at its initial value for the whole life of this file, and the
94
+ // prose above it was still true.
95
+ const { selected: chosen } = select(await loadRules(path), fp.stack ?? [], fp.facts ?? []);
96
+
97
+ const row = {
98
+ name,
99
+ path,
100
+ adoption,
101
+ id: manifest.manifest?.id ?? null,
102
+ stack: fp.stack ?? [],
103
+ declaredStack: manifest.manifest?.stack ?? [],
104
+ facts: fp.facts ?? [],
105
+ profiles: chosen.map((r) => r.id),
106
+ pinnedSha: manifest.manifest?.projection?.simtDevSha ?? null,
107
+ recordedSha: region.recordedSha ?? null,
108
+ region: region.status,
109
+ regionDetail: region.detail ?? null,
110
+ surfaces: manifest.manifest?.surfaces ?? null,
111
+ ruleFiles: {
112
+ total: ruleFindings.filter((f) => f.status !== 'orphaned').length,
113
+ current: ruleFindings.filter((f) => f.status === 'current').length,
114
+ stale: ruleFindings.filter((f) => f.status !== 'current' && f.status !== 'orphaned').map((f) => f.file),
115
+ // Managed rule files this run would not write, counted apart from the rule files.
116
+ // They are a different kind of thing: `sync` cannot repair them and would never
117
+ // write them again, so they are a note with a remedy and not a problem
118
+ // (ADR-021 ruling 6).
119
+ orphaned: ruleFindings.filter((f) => f.status === 'orphaned').map((f) => f.file),
120
+ },
121
+ pointers: pointers.map((p) => ({ file: p.file, state: p.status })),
122
+ hooksRouted: hooks.routed,
123
+ hooksComplete: hooks.complete,
124
+ gatesDeclared: declaredGates(manifest.manifest),
125
+ gateProblems: [],
126
+ foreignHooks: [],
127
+ problems,
128
+ notes,
129
+ };
130
+
131
+ // A rule file on any surface that is not current is a defect the row would
132
+ // otherwise hide. It names the files, because "something is stale" is not
133
+ // actionable and this command exists to be acted on.
134
+ //
135
+ // Gated on adoption, and that gate is the whole point. A repo that has never been
136
+ // adopted has no rule files at all, and reporting them as "not current" turns the
137
+ // normal mid-migration state into a red estate — the same mistake ADR-018 records
138
+ // for hooks, where an unadopted repo's own hooks are a note rather than a problem.
139
+ if (row.ruleFiles.orphaned.length > 0) {
140
+ notes.push(
141
+ `${row.ruleFiles.orphaned.length} generated rule file(s) repeat text already folded into AGENTS.md (${row.ruleFiles.orphaned.join(', ')}); delete them, or the reader loads the rule twice. simt-dev will not remove a file it wrote.`,
142
+ );
143
+ }
144
+
145
+ if (adoption === 'adopted' && row.ruleFiles.stale.length > 0) {
146
+ problems.push(
147
+ `${row.ruleFiles.stale.length} of ${row.ruleFiles.total} rule files are not current (${row.ruleFiles.stale.join(', ')}); run: ${CLI} sync --yes`,
148
+ );
149
+ }
150
+
151
+ if (manifest.error) {
152
+ problems.push(`${MANIFEST_FILE} ${manifest.error}`);
153
+ }
154
+
155
+ // --- region --------------------------------------------------------------
156
+ // Only meaningful for an adopted repo. An unmanaged repo has no manifest, so
157
+ // its `absent` region is a consequence of not having adopted, not a second
158
+ // independent defect - reporting both would double-count one root cause.
159
+ if (adoption === 'adopted') {
160
+ if (region.status === 'drift') {
161
+ // A drift has two causes with different fixes, and the SHA says which. When
162
+ // the recorded SHA matches this simt-dev, the region was hand-edited and
163
+ // `simt-dev sync --yes` is right; when it does not, the repo is simply behind and the
164
+ // same command is also right - but the message must not claim a version
165
+ // mismatch when the versions are identical, because that sends the reader
166
+ // looking for a pin that is already correct.
167
+ const sameSha = region.recordedSha === context.expectedSha;
168
+ problems.push(
169
+ sameSha
170
+ ? `the generated region was hand-edited; restore it with: ${CLI} sync --yes`
171
+ : `projection is ${region.recordedSha ?? '(no sha)'} but this simt-dev is ${context.expectedSha}; run: ${CLI} sync --yes`,
172
+ );
173
+ } else if (region.status === 'malformed') {
174
+ problems.push(`generated region is malformed: ${region.detail}; run: ${CLI} sync --yes`);
175
+ } else if (region.status === 'unmanaged') {
176
+ problems.push(`adopted but AGENTS.md has no generated region; run: ${CLI} sync --yes`);
177
+ } else if (region.status === 'absent') {
178
+ problems.push(`adopted but AGENTS.md does not exist; run: ${CLI} sync --yes`);
179
+ } else if (region.status === 'current' && row.pinnedSha && row.pinnedSha !== context.expectedSha) {
180
+ // The region matches this simt-dev but the manifest pins an older one. The
181
+ // next sync would stamp the newer SHA silently, so the pin is the thing
182
+ // that is stale - and it is the value `simt-dev upgrade` reads.
183
+ problems.push(
184
+ `manifest pins simt-dev ${row.pinnedSha}, this is ${context.expectedSha}; run: ${CLI} upgrade`,
185
+ );
186
+ }
187
+ }
188
+
189
+ // --- declared stack vs detected stack ------------------------------------
190
+ // `simt-dev.json` records what the repo claims to be; fingerprint records what
191
+ // the files prove. Selection uses detection, so a stale claim does not change
192
+ // which profile is projected - but it DOES mean the manifest misdescribes the
193
+ // repo, and a reader of the manifest would be misled. Reported rather than
194
+ // silently reconciled: picking a winner would hide the edit that caused it.
195
+ //
196
+ // An empty declared stack counts as a disagreement when detection found
197
+ // something. That is not pedantry: `init` writes the detected stack, so an empty
198
+ // one means either the repo was adopted before the markers existed or someone
199
+ // emptied it by hand, and in both cases the manifest is wrong.
200
+ if (adoption === 'adopted') {
201
+ const declared = new Set(row.declaredStack);
202
+ const detected = new Set(row.stack);
203
+ const extra = row.declaredStack.filter((t) => !detected.has(t));
204
+ const missing = row.stack.filter((t) => !declared.has(t));
205
+ if (extra.length > 0 || missing.length > 0) {
206
+ const parts = [];
207
+ if (extra.length > 0) parts.push(`declares ${extra.join(', ')} but no marker proves it`);
208
+ if (missing.length > 0) parts.push(`detected ${missing.join(', ')} but the manifest omits it`);
209
+ problems.push(`manifest stack disagrees with detection: ${parts.join('; ')}`);
210
+ }
211
+ }
212
+
213
+ // --- hooks ---------------------------------------------------------------
214
+ // A repo running its own hooks is a fact about today, independent of whether
215
+ // simt-dev has been adopted. It is a NOTE, not a problem: adopting this repo is
216
+ // the normal mid-migration state, and a dashboard that goes red for it teaches
217
+ // everyone to ignore the dashboard. What it costs is only visible at install
218
+ // time, which is where the takeover guard asks for consent.
219
+ row.foreignHooks = hooks.foreign.map((h) => ({ event: h.event, bytes: h.bytes }));
220
+ for (const h of hooks.foreign) {
221
+ notes.push(`runs its own ${h.event} (${h.bytes} bytes); hooks install will replace it and asks first`);
222
+ }
223
+
224
+ if (adoption === 'adopted') {
225
+ if (!hooks.routed) {
226
+ problems.push(
227
+ hooks.current === null
228
+ ? `no core.hooksPath; commits are ungated. run: ${CLI} hooks install --yes`
229
+ : `core.hooksPath is "${hooks.current}", not the shared dispatcher; run: ${CLI} hooks install --yes`,
230
+ );
231
+ } else if (!hooks.complete) {
232
+ for (const h of hooks.problems) problems.push(`hooks: ${h}`);
233
+ }
234
+ }
235
+
236
+ // --- gates ---------------------------------------------------------------
237
+ // A declared gate the dispatcher does not know is a hard failure at commit
238
+ // time (step 8 fails closed), so it is a defect here. A command-kind gate with
239
+ // no command declared is the same failure one step later.
240
+ if (adoption === 'adopted') {
241
+ const commands = manifest.manifest?.gates?.commands ?? {};
242
+ const invoked = new Set([...row.gatesDeclared, ...Object.keys(commands)]);
243
+ const unknown = [...invoked].filter((g) => !context.gates.has(g)).sort();
244
+ const uncommanded = [...invoked].filter(
245
+ (g) => context.gates.has(g) && context.gateKinds?.get(g) === 'command' && !commands[g],
246
+ );
247
+ const neverRun = Object.keys(commands)
248
+ .filter((c) => !row.gatesDeclared.includes(c))
249
+ .sort();
250
+
251
+ row.gateProblems = [
252
+ ...unknown.map((g) => `gate "${g}" is not a known gate`),
253
+ ...uncommanded.map((g) => `gate "${g}" is a command gate with no declared command`),
254
+ ...neverRun.map((c) => `declared command "${c}" is in no gate list`),
255
+ ];
256
+ problems.push(...row.gateProblems);
257
+ }
258
+
259
+ return row;
260
+ }
261
+
262
+ function declaredGates(manifest) {
263
+ if (!manifest) return [];
264
+ return [...(manifest.gates?.preCommit ?? []), ...(manifest.gates?.prePush ?? [])];
265
+ }
266
+
267
+ /**
268
+ * The headline facts about an estate, aggregated over rows.
269
+ *
270
+ * Counts, not verdicts. A single summary line an agent can read before deciding
271
+ * whether it needs the table - and the numbers that make a migration legible
272
+ * ("12 of 16 repos still unmanaged") in a way a per-repo list cannot.
273
+ */
274
+ export function summarise(rows, extra = {}) {
275
+ const count = (pred) => rows.filter(pred).length;
276
+ const shas = new Map();
277
+ for (const r of rows) {
278
+ const sha = r.pinnedSha ?? '(none)';
279
+ shas.set(sha, (shas.get(sha) ?? 0) + 1);
280
+ }
281
+ return {
282
+ repos: rows.length,
283
+ adopted: count((r) => r.adoption === 'adopted'),
284
+ unmanaged: count((r) => r.adoption === 'unmanaged'),
285
+ broken: count((r) => r.adoption === 'broken'),
286
+ drifted: count((r) => r.region === 'drift'),
287
+ current: count((r) => r.region === 'current'),
288
+ noRegion: count((r) => r.region === 'unmanaged' || r.region === 'absent'),
289
+ malformed: count((r) => r.region === 'malformed'),
290
+ hooksRouted: count((r) => r.hooksRouted),
291
+ staleRuleFiles: rows.reduce((n, r) => n + (r.ruleFiles?.stale?.length ?? 0), 0),
292
+ withProblems: count((r) => r.problems.length > 0),
293
+ withOwnHooks: count((r) => (r.foreignHooks ?? []).length > 0),
294
+ shas: [...shas.entries()].sort((a, b) => (a[0] === '(none)' ? 1 : b[0] === '(none)' ? -1 : b[1] - a[1])),
295
+ ...extra,
296
+ };
297
+ }
298
+
299
+ export { knownGates };