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,140 @@
1
+ // Generate the POSIX sh hook shim.
2
+ //
3
+ // The shim is intentionally the smallest thing that can work: it resolves the
4
+ // dispatcher and execs it. Everything else lives in Node, where it can be
5
+ // tested. The event name is baked in at generation time rather than derived from
6
+ // `$0`, because a repo may symlink or copy `pre-push` into `pre-commit` and
7
+ // `$0` would then name the wrong hook - a silent way to run the wrong gates.
8
+ //
9
+ // POSIX sh only. ADR-012 dropped Windows, so there is exactly one code path and
10
+ // no `.cmd` shim to keep in sync.
11
+ //
12
+ // Git's own arguments are deliberately not forwarded. `pre-push` passes a remote
13
+ // name and URL, which argv would then misread as command arguments.
14
+ //
15
+ // The resolution block itself is NOT here: it is shared with the CI workflow, which
16
+ // has to find the binary by the same rules or the gate passes locally and cannot
17
+ // start in CI. See src/locate.mjs.
18
+
19
+ import { renderLocator } from '../locate.mjs';
20
+ import { BIN_ENV, CLI, MANIFEST_FILE } from '../name.mjs';
21
+
22
+ /** Events the shared dispatcher knows how to run. */
23
+ export const EVENTS = ['pre-commit', 'pre-push'];
24
+
25
+ /** Directory the shims are written to inside a repo. */
26
+ export const HOOKS_DIR = '.simt-hooks';
27
+
28
+ /** Name of the git config key that points at the shim directory. */
29
+ export const HOOKS_PATH_KEY = 'core.hooksPath';
30
+
31
+ export function fileFor(event) {
32
+ return `${HOOKS_DIR}/${event}`;
33
+ }
34
+
35
+ /**
36
+ * @param {string} event one of EVENTS
37
+ * @returns {string} POSIX sh source, LF-terminated, executable
38
+ */
39
+ export function renderShim(event) {
40
+ if (!EVENTS.includes(event)) {
41
+ throw new Error(`unknown hook event ${event}; known: ${EVENTS.join(', ')}`);
42
+ }
43
+ const D = '$';
44
+ return `#!/bin/sh
45
+ # Generated by simt-dev. Do not edit.
46
+ #
47
+ # Every decision this hook makes is declared in ${MANIFEST_FILE}, so a reviewer reads
48
+ # the gate list in a diff instead of reverse-engineering shell. Reinstall with:
49
+ # ${CLI} hooks install --yes
50
+ #
51
+ # This file only locates the dispatcher and hands over. It resolves in order:
52
+ # 1. ${D}SIMT_DEV_BIN explicit override, for CI images and odd checkouts
53
+ # 2. node_modules/ the pinned project dependency
54
+ # 3. simt-dev on PATH a global install
55
+ # If none resolves it REFUSES rather than exiting 0. A hook that cannot verify
56
+ # anything must never report success - that is what teaches authors to reach for
57
+ # --no-verify.
58
+
59
+ set -eu
60
+
61
+ ROOT="${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)"
62
+
63
+ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
64
+ subject: 'the dispatcher',
65
+ remedy: `REMEDY set ${BIN_ENV} to the ${CLI} binary, or unset ${HOOKS_PATH_KEY} to opt out`,
66
+ })}`;
67
+ }
68
+
69
+ /**
70
+ * A line only a chained shim carries.
71
+ *
72
+ * The same reason `renderShim` bakes in its event rather than reading `$0`: a
73
+ * shim's name cannot identify it, because the whole point of a chained shim is that
74
+ * it is called `post-commit` because the repository called its hook that. Identity
75
+ * has to come from the bytes, so this marker is what `hooks status` and `compare`
76
+ * read to tell our shim from somebody else's.
77
+ */
78
+ export const CHAIN_MARKER = '# simt-dev chain-shim: the repository hook below still runs';
79
+
80
+ /**
81
+ * A shim for an event this tool does not gate, so the repository's own hook keeps
82
+ * running after routing.
83
+ *
84
+ * `core.hooksPath` does not select a directory for the events this tool cares about
85
+ * and leave the rest alone: git reads EVERY hook from the named directory and from
86
+ * nowhere else. So the moment routing lands, a repository whose `post-commit` does
87
+ * real work loses it - not loudly, because nothing failed, only silently, because
88
+ * there is now no `post-commit` where git looks for one.
89
+ *
90
+ * There is nothing to enforce on such an event, so this shim does not run the
91
+ * dispatcher at all. It runs the hook it found and forwards git's arguments, which
92
+ * the dispatcher shim deliberately does not do: the program on the other end here is
93
+ * the repository's own hook and expects them.
94
+ *
95
+ * `target` is relative to the repository's top level wherever it can be. A path
96
+ * baked in absolute would break the first time anyone moves or clones the
97
+ * repository somewhere else, which for a hook is the difference between a gate that
98
+ * runs and one that stops existing.
99
+ *
100
+ * @param {string} event the hook event, e.g. `post-commit`. Not required to be in
101
+ * {@link EVENTS}: that list is what this tool gates, and chaining exists precisely
102
+ * for the events it does not.
103
+ * @param {string} target the repository's hook, relative to its top level or absolute
104
+ * @returns {string} POSIX sh source, LF-terminated, executable
105
+ */
106
+ export function renderChainedShim(event, target) {
107
+ const D = '$';
108
+ return `#!/bin/sh
109
+ # Generated by simt-dev. Do not edit.
110
+ ${CHAIN_MARKER}
111
+ #
112
+ # simt-dev does not gate ${event}, so nothing in this file enforces anything. It is
113
+ # here because git reads every hook from ${HOOKS_DIR} once core.hooksPath points
114
+ # there, and without this file the repository's own ${event} would stop running.
115
+ #
116
+ # It runs, in order:
117
+ # 1. ${target}
118
+ # forwarding git's arguments, which that hook expects. Reinstall with:
119
+ # ${CLI} hooks install --yes
120
+
121
+ set -eu
122
+
123
+ ROOT="${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)"
124
+
125
+ TARGET='${target}'
126
+ case "${D}TARGET" in
127
+ /*) ;;
128
+ *) TARGET="${D}ROOT/${D}TARGET" ;;
129
+ esac
130
+
131
+ if [ ! -x "${D}TARGET" ]; then
132
+ echo "simt: ${event} is chained to a hook that is not there." >&2
133
+ echo " expected: ${D}TARGET" >&2
134
+ echo "REMEDY restore that file, or delete ${fileFor(event)} to stop chaining it." >&2
135
+ exit 1
136
+ fi
137
+
138
+ exec "${D}TARGET" "${D}@"
139
+ `;
140
+ }
@@ -0,0 +1,118 @@
1
+ // Work out which directory git will actually run hooks from.
2
+ //
3
+ // This exists because "core.hooksPath is unset" does NOT mean "git runs no
4
+ // hooks". Unset means git falls back to the default location, which in a normal
5
+ // repository is `.git/hooks` and in a linked worktree is the *main* repository's
6
+ // `.git/hooks`. A tool that reads unset as "none" reports a live hand-written
7
+ // hook as absent, and then installs over it without asking.
8
+ //
9
+ // `git rev-parse --git-path hooks` looks like the answer and is not one:
10
+ // normal repo, unset -> ".git/hooks" (relative, correct here)
11
+ // worktree, configured -> "hooks" (relative to the worktree top)
12
+ // worktree, unset -> "./" <- nonsense; the worktree's .git
13
+ // is a FILE, so this names the
14
+ // whole repository
15
+ // An implementation that trusted it would enumerate a worktree's entire root as
16
+ // its hooks directory and report every file in the repo as a hook. So the rule is
17
+ // resolved here, from the two facts git does report reliably.
18
+ //
19
+ // ADR-012 dropped Windows, but nothing here is POSIX-specific; the worktree
20
+ // handling is the only subtlety and it is the same on every platform.
21
+
22
+ import { execFile } from 'node:child_process';
23
+ import { readdir, stat } from 'node:fs/promises';
24
+ import { isAbsolute, join, resolve } from 'node:path';
25
+ import { promisify } from 'node:util';
26
+
27
+ const exec = promisify(execFile);
28
+
29
+ async function git(root, args) {
30
+ const { stdout } = await exec('git', args, { cwd: root });
31
+ return stdout.trim();
32
+ }
33
+
34
+ /**
35
+ * The directory git runs hooks from, and which of the two rules produced it.
36
+ *
37
+ * @param {string} root
38
+ * @returns {Promise<{dir:string, how:'configured'|'default'|'not-a-repo', top:string|null,
39
+ * commonDir:string|null, reason?:string}>}
40
+ */
41
+ export async function resolveHooksDir(root) {
42
+ let top;
43
+ try {
44
+ top = await git(root, ['rev-parse', '--show-toplevel']);
45
+ } catch {
46
+ return { dir: join(root, '.git', 'hooks'), how: 'not-a-repo', top: null, commonDir: null, reason: 'not a git repository' };
47
+ }
48
+
49
+ let configured = null;
50
+ try {
51
+ const value = await git(root, ['config', '--get', 'core.hooksPath']);
52
+ configured = value === '' ? null : value;
53
+ } catch {
54
+ configured = null;
55
+ }
56
+
57
+ // Rule 1: core.hooksPath wins. A relative value resolves against the top level
58
+ // of the working tree, which is what makes a `hooks` directory work.
59
+ if (configured !== null) {
60
+ return {
61
+ dir: isAbsolute(configured) ? configured : resolve(top, configured),
62
+ how: 'configured',
63
+ top,
64
+ commonDir: null,
65
+ };
66
+ }
67
+
68
+ // Rule 2: the default, which is inside the COMMON git dir. A linked worktree
69
+ // has its own git dir but shares hooks with its parent, so this is the one
70
+ // step where `root/.git` is the wrong answer.
71
+ let common = '.git';
72
+ try {
73
+ common = await git(root, ['rev-parse', '--git-common-dir']);
74
+ } catch {
75
+ // A repository whose common dir cannot be read still has a working tree;
76
+ // the top-level .git guess is the only thing left and may be right.
77
+ }
78
+ return {
79
+ dir: isAbsolute(common) ? join(common, 'hooks') : resolve(top, common, 'hooks'),
80
+ how: 'default',
81
+ top,
82
+ commonDir: common,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Executable hooks git will actually run, excluding git's own `*.sample`
88
+ * templates and anything simt-dev generated.
89
+ *
90
+ * @param {string} root
91
+ * @returns {Promise<{event:string, path:string, bytes:number}[]>}
92
+ */
93
+ export async function foreignHooks(root) {
94
+ const { dir, how } = await resolveHooksDir(root);
95
+ if (how === 'not-a-repo') return [];
96
+
97
+ let entries;
98
+ try {
99
+ entries = await readdir(dir);
100
+ } catch {
101
+ return [];
102
+ }
103
+
104
+ const found = [];
105
+ for (const name of entries.sort()) {
106
+ if (name.endsWith('.sample')) continue;
107
+ const path = join(dir, name);
108
+ let info;
109
+ try {
110
+ info = await stat(path);
111
+ } catch {
112
+ continue;
113
+ }
114
+ if (!info.isFile() || (info.mode & 0o111) === 0) continue;
115
+ found.push({ event: name, path, bytes: info.size });
116
+ }
117
+ return found;
118
+ }
@@ -0,0 +1,218 @@
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
+ * Whether the repository tracks the identity file, which it should not.
178
+ *
179
+ * @param {string} root
180
+ * @returns {Promise<boolean>}
181
+ */
182
+ export async function tracksIdentity(root) {
183
+ const listed = await git(root, ['ls-files', '--error-unmatch', IDENTITY_FILE]);
184
+ return listed === IDENTITY_FILE;
185
+ }
186
+
187
+ /**
188
+ * Add the identity file to .gitignore, if it is not already there.
189
+ *
190
+ * Matching on the exact line rather than a substring, because a `.gitignore`
191
+ * entry of `/git.config` or `git.config` both mean it while a comment mentioning
192
+ * it means nothing, and appending a duplicate of an entry that is already present
193
+ * is how a file ends up listed twice.
194
+ *
195
+ * @param {string} root
196
+ * @returns {Promise<boolean>} whether the file was changed
197
+ */
198
+ export async function ignoreIdentity(root) {
199
+ const path = join(root, '.gitignore');
200
+ let text;
201
+ try {
202
+ text = await readFile(path, 'utf8');
203
+ } catch {
204
+ text = '';
205
+ }
206
+ const already = text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
207
+ if (already) return false;
208
+ const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
209
+ const block =
210
+ `${prefix}\n` +
211
+ '# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
212
+ '# it carries whoever is committing into the tree, and a repository has more than\n' +
213
+ '# one of those.\n' +
214
+ `${IDENTITY_FILE}\n`;
215
+ const { writeFile } = await import('node:fs/promises');
216
+ await writeFile(path, text + block);
217
+ return true;
218
+ }
@@ -0,0 +1,75 @@
1
+ // Find the simt-dev binary, in POSIX sh, as one block of text.
2
+ //
3
+ // Two generated files run simt-dev on someone else's machine and must agree about
4
+ // how it is found: the hook shim (src/hooks/shim.mjs) and the CI workflow
5
+ // (src/ci/workflow.mjs). They were written separately and the second copy would
6
+ // have been a real divergence risk - a change to the resolution order that reaches
7
+ // the hook but not the workflow means the gate passes locally and is unfindable in
8
+ // CI, which is the failure this whole tool is about.
9
+ //
10
+ // The caller's shell is expected to have `set -eu` and a `ROOT` already defined:
11
+ // the shim derives ROOT from git, the workflow takes it from the runner. That is why
12
+ // this is a block and not a script.
13
+
14
+ import { BIN_ENV, BIN_REL, CLI, PACKAGE_NAME } from './name.mjs';
15
+
16
+ /**
17
+ * @param {string} command arguments to hand the tool, with `$ROOT` spelled as a shell
18
+ * expansion - `hooks run pre-commit --root "$ROOT"`, `check --root "$ROOT"`
19
+ * @param {object} [opts]
20
+ * @param {string} [opts.subject] what it could not find, as the caller names it:
21
+ * the shim says "the dispatcher", the workflow says the command
22
+ * @param {string} [opts.remedy] the last line, which is the only part that differs
23
+ * between a hook (which can be opted out of) and a CI job (which cannot)
24
+ * @returns {string} POSIX sh source
25
+ */
26
+ export function renderLocator(command, { subject = CLI, remedy = `REMEDY run: npm i -D ${PACKAGE_NAME}` } = {}) {
27
+ const D = '$';
28
+ return `# ${D}${BIN_ENV} must name ONE program: an executable on PATH, or a .mjs entry
29
+ # point to hand to node. It cannot be a command line, because there is no safe way
30
+ # to split one in sh without eval, and a value with a space is refused rather than
31
+ # half-executed - running from a source checkout is normal for anyone working on
32
+ # this project itself, so this path has to work with no install step.
33
+ run_simt_dev() {
34
+ case "${D}1" in
35
+ *' '*)
36
+ echo "${CLI}: ${D}${BIN_ENV} names a command line, not a program." >&2
37
+ echo " set it to a single executable or a .mjs path, e.g." >&2
38
+ echo " ${BIN_ENV}=/path/to/checkout/${BIN_REL}" >&2
39
+ exit 1
40
+ ;;
41
+ *.mjs)
42
+ # shift first: "${D}@" still holds ${D}1, which would pass the program path to
43
+ # itself as the first argument.
44
+ program="${D}1"
45
+ shift
46
+ exec node "${D}program" "${D}@"
47
+ ;;
48
+ *)
49
+ exec "${D}1" "${D}@"
50
+ ;;
51
+ esac
52
+ }
53
+
54
+ if [ -n "${D}{${BIN_ENV}:-}" ]; then
55
+ run_simt_dev "${D}${BIN_ENV}" ${command}
56
+ fi
57
+
58
+ if [ -f "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ]; then
59
+ exec node "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ${command}
60
+ fi
61
+
62
+ if command -v ${CLI} >/dev/null 2>&1; then
63
+ exec ${CLI} ${command}
64
+ fi
65
+
66
+ echo "${CLI}: cannot locate ${subject}." >&2
67
+ # Every expansion in these diagnostics must carry a default. Under \`set -eu\` an
68
+ # unbound variable aborts the shell, so an unguarded ${D}${BIN_ENV} here would
69
+ # kill the message that exists to explain the failure - and this block only runs
70
+ # when nothing resolved, which is the one moment the reader needs it.
71
+ echo " looked for: ${D}{${BIN_ENV}:-<unset>}, ${D}ROOT/node_modules/${PACKAGE_NAME}, ${CLI} on PATH" >&2
72
+ echo "${remedy}" >&2
73
+ exit 1
74
+ `;
75
+ }