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.
- python/simt_dev/__init__.py +32 -0
- python/simt_dev/cli.py +225 -0
- simt_dev/__init__.py +32 -0
- simt_dev/cli.py +225 -0
- simt_dev/vendored/CHANGELOG.md +166 -0
- simt_dev/vendored/bin/simt-dev.mjs +28 -0
- simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
- simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
- simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
- simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
- simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
- simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
- simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
- simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
- simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
- simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
- simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
- simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
- simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
- 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
- simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
- simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
- simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
- simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
- simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
- simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
- simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
- simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
- simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
- simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
- simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
- simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
- simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
- 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
- simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
- simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
- simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
- simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- simt_dev/vendored/docs/decisions/README.md +530 -0
- simt_dev/vendored/graph/skills.graph.json +1622 -0
- simt_dev/vendored/package.json +43 -0
- simt_dev/vendored/rules/core.md +76 -0
- simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
- simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
- simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
- simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
- simt_dev/vendored/rules/stacks/android.md +63 -0
- simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
- simt_dev/vendored/rules/stacks/connectiq.md +62 -0
- simt_dev/vendored/rules/stacks/container.md +31 -0
- simt_dev/vendored/rules/stacks/jvm.md +54 -0
- simt_dev/vendored/rules/stacks/node.md +45 -0
- simt_dev/vendored/rules/stacks/python.md +48 -0
- simt_dev/vendored/rules/stacks/react-native.md +58 -0
- simt_dev/vendored/rules/stacks/react-web.md +61 -0
- simt_dev/vendored/rules/stacks/typescript.md +21 -0
- simt_dev/vendored/rules/stacks/uv.md +32 -0
- simt_dev/vendored/src/argv.mjs +116 -0
- simt_dev/vendored/src/ci/dependabot.mjs +268 -0
- simt_dev/vendored/src/ci/workflow.mjs +360 -0
- simt_dev/vendored/src/commands/ci.mjs +285 -0
- simt_dev/vendored/src/commands/compare.mjs +195 -0
- simt_dev/vendored/src/commands/graph.mjs +73 -0
- simt_dev/vendored/src/commands/help.mjs +108 -0
- simt_dev/vendored/src/commands/hooks.mjs +471 -0
- simt_dev/vendored/src/commands/index.mjs +241 -0
- simt_dev/vendored/src/commands/init.mjs +632 -0
- simt_dev/vendored/src/commands/mcp.mjs +63 -0
- simt_dev/vendored/src/commands/rules.mjs +374 -0
- simt_dev/vendored/src/commands/skills.mjs +647 -0
- simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
- simt_dev/vendored/src/commands/sync.mjs +331 -0
- simt_dev/vendored/src/commands/upgrade.mjs +202 -0
- simt_dev/vendored/src/compare/discover.mjs +212 -0
- simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
- simt_dev/vendored/src/compare/row.mjs +299 -0
- simt_dev/vendored/src/decisions/index.mjs +536 -0
- simt_dev/vendored/src/graph/load.mjs +541 -0
- simt_dev/vendored/src/hooks/capability.mjs +129 -0
- simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
- simt_dev/vendored/src/hooks/gates.mjs +144 -0
- simt_dev/vendored/src/hooks/resolve.mjs +194 -0
- simt_dev/vendored/src/hooks/shim.mjs +140 -0
- simt_dev/vendored/src/hooks/where.mjs +118 -0
- simt_dev/vendored/src/identity.mjs +218 -0
- simt_dev/vendored/src/locate.mjs +75 -0
- simt_dev/vendored/src/mcp/server.mjs +167 -0
- simt_dev/vendored/src/mcp/tools.mjs +410 -0
- simt_dev/vendored/src/name.mjs +95 -0
- simt_dev/vendored/src/output/index.mjs +96 -0
- simt_dev/vendored/src/projection/baselines.mjs +209 -0
- simt_dev/vendored/src/projection/conflicts.mjs +205 -0
- simt_dev/vendored/src/projection/manifest.mjs +478 -0
- simt_dev/vendored/src/projection/markers.mjs +349 -0
- simt_dev/vendored/src/projection/mdc.mjs +327 -0
- simt_dev/vendored/src/projection/project.mjs +504 -0
- simt_dev/vendored/src/projection/rules.mjs +640 -0
- simt_dev/vendored/src/projection/surfaces.mjs +398 -0
- simt_dev/vendored/src/projection/targets.mjs +221 -0
- simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
- simt_dev/vendored/src/resolve/near.mjs +61 -0
- simt_dev/vendored/src/resolve/resolve.mjs +457 -0
- simt_dev/vendored/src/resolve/source.mjs +105 -0
- simt_dev/vendored/src/toolchain.mjs +202 -0
- simt_dev/vendored/src/tsconfig.mjs +314 -0
- simt_dev/vendored/src/upgrade/plan.mjs +203 -0
- simt_dev/vendored/src/workspace.mjs +289 -0
- simt_dev-0.2.0.dist-info/METADATA +150 -0
- simt_dev-0.2.0.dist-info/RECORD +138 -0
- simt_dev-0.2.0.dist-info/WHEEL +4 -0
- simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
- 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 };
|