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,96 @@
1
+ // Exit codes and the output layer.
2
+ //
3
+ // Agent tools optimise for tokens; terminal tools optimise for human eyes.
4
+ // Same data, different rendering. Nothing here emits ANSI, prompts, spinners or
5
+ // boxes, because a prompt is a hang for an agent and a spinner is noise in a log.
6
+ //
7
+ // See docs/plans/0002-simt-dev-cli.md section 6.
8
+
9
+ import { CLI } from '../name.mjs';
10
+ /** Stable machine codes. A non-zero exit always carries one of these. */
11
+ export const EXIT = {
12
+ OK: 0,
13
+ FAIL: 1, // a gate failed: drift, policy violation, missing evidence
14
+ USAGE: 2, // bad arguments; the remedy is in the message
15
+ CONFIG: 3, // simt-dev.json missing or invalid
16
+ REGISTRY: 4, // skills.graph.json invalid: orphan, dangling edge, cycle
17
+ UNSUPPORTED: 5, // the request cannot be satisfied on this platform or repo
18
+ };
19
+
20
+ const REMEDY = {
21
+ [EXIT.USAGE]: 'run: simt-dev --help',
22
+ [EXIT.CONFIG]: `run: ${CLI} init`,
23
+ [EXIT.REGISTRY]: `run: ${CLI} graph lint`,
24
+ };
25
+
26
+ /**
27
+ * Report a failure and return its exit code. Callers `return fail(...)` so the
28
+ * code is impossible to forget.
29
+ */
30
+ export function fail(message, code = EXIT.FAIL, remedy) {
31
+ // The fallback must be a command, never a path. `docs/decisions/` was the old
32
+ // default and it was only ever resolvable from inside simt-dev's own checkout;
33
+ // a reader in a consumer repo got a remedy naming a file that does not exist
34
+ // where they are. Every remedy here is something the reader can run.
35
+ const resolved = remedy ?? REMEDY[code] ?? 'run: simt-dev --help';
36
+ process.stderr.write(`ERROR ${code} ${message}\nREMEDY ${resolved}\n`);
37
+ return code;
38
+ }
39
+
40
+ /** Whether to emit plain text (default) or structured JSON. */
41
+ export function isJson(flags) {
42
+ return flags.json === true;
43
+ }
44
+
45
+ /**
46
+ * Whether this invocation may change the filesystem, and whether it is a rehearsal.
47
+ *
48
+ * This is the only place that decides, because there was a third copy of the same
49
+ * arithmetic in the commands and they did not agree: `upgrade` consulted `--yes`
50
+ * and ignored `--dry-run`, so `--yes --dry-run` wrote; `skills install` read
51
+ * neither. A flag pair that means the same thing to a person must mean the same
52
+ * thing to every command, or `--dry-run` stops being a promise.
53
+ *
54
+ * `dryRun` is reported separately from `apply` because a run that was not
55
+ * authorised is a dry run in effect even when nobody asked for one, and the report
56
+ * has to say which happened rather than echoing the flags back.
57
+ */
58
+ export function writeMode(flags) {
59
+ const dryRun = flags.dryRun === true;
60
+ return { dryRun, apply: flags.yes === true && !dryRun };
61
+ }
62
+
63
+ export function isTty() {
64
+ return process.stdout.isTTY === true && !process.env.NO_COLOR;
65
+ }
66
+
67
+ /**
68
+ * Emit a block of text under a heading, respecting --max-bytes.
69
+ * Truncation is always announced with an explicit line, never a silent ellipsis:
70
+ * an agent that cannot tell a truncated answer from a complete one will guess.
71
+ */
72
+ export function emit(text, { maxBytes = 0, stream = process.stdout } = {}) {
73
+ const body = text.endsWith('\n') ? text : `${text}\n`;
74
+ const bytes = Buffer.byteLength(body);
75
+ if (maxBytes > 0 && bytes > maxBytes) {
76
+ const slice = Buffer.from(body).subarray(0, maxBytes).toString('utf8');
77
+ const cut = slice.lastIndexOf('\n');
78
+ stream.write(cut > 0 ? `${slice.slice(0, cut)}\n` : slice);
79
+ stream.write(`TRUNCATED ${bytes - maxBytes} bytes omitted, rerun with --max-bytes 0\n`);
80
+ return false;
81
+ }
82
+ stream.write(body);
83
+ return true;
84
+ }
85
+
86
+ export function emitJson(value, { stream = process.stdout } = {}) {
87
+ stream.write(`${JSON.stringify(value, null, 2)}\n`);
88
+ }
89
+
90
+ /**
91
+ * Print to stderr so machine-readable stdout stays parseable. Used for
92
+ * diagnostics that accompany a real result, never for the result itself.
93
+ */
94
+ export function note(message, { stream = process.stderr } = {}) {
95
+ stream.write(`${message}\n`);
96
+ }
@@ -0,0 +1,209 @@
1
+ // The tooling baselines: settings that belong to the estate, in files the repo owns.
2
+ //
3
+ // Plan 1 section 2.2 named these as marker-managed before any of them existed —
4
+ // "Same for `.editorconfig`, `.prettierrc`, `.gitattributes`. Local content is never
5
+ // clobbered; shared content can never drift silently" — and this module is that
6
+ // sentence, restricted to the files whose syntax can actually carry a region.
7
+ //
8
+ // The restriction is not caution, it is grammar. A region is delimited by comments,
9
+ // so it can only live in a file that has them and whose comment character the
10
+ // reading tool ignores. `.editorconfig` qualifies: `#` is a comment in INI and every
11
+ // editorconfig parser honours it. `.gitattributes` qualifies for the same reason and
12
+ // for a stronger one: `#` is a comment to git, and gitattributes is last-match-wins,
13
+ // so the shared claim goes first and every repo's own patterns still win beneath it.
14
+ // `tsconfig.base.json` and `.prettierrc.json` do not — JSON has no comment syntax — and
15
+ // `.nvmrc` is a bare version string. Those three are ruled out by ADR-025 and ADR-026,
16
+ // not deferred by accident; a managed region in a JSON file would have to be a string
17
+ // literal that prettier and tsc both choke on.
18
+ //
19
+ // One value is deliberately absent from `.editorconfig`: `end_of_line`. It reads like an
20
+ // editor setting and is not one. It decides whether a save rewrites every line of every
21
+ // file, which is a claim about what belongs in the repository, and that claim is made in
22
+ // `.gitattributes` where git reads it — which is why the second baseline exists. Setting
23
+ // it in the editorconfig would have changed every checkout of every repo on the day it
24
+ // landed, and the repo that wanted CRLF could only have opted out by deleting the
25
+ // indentation baseline with it.
26
+ //
27
+ // Two values are absent for the same class of reason, and the second one is here
28
+ // because it was shipped once and read as settled by two reviews. A formatter's own
29
+ // options are not editorconfig properties: this file shipped `[prettier] singleQuote =
30
+ // true`, and a section header in editorconfig is a glob over paths, so that section
31
+ // matched a file named `prettier` and no key in it is one any reader honours — the
32
+ // specification defines nine and `singleQuote` is not among them, and prettier's own
33
+ // mapping reads six properties, none of them that one. It was decoration describing an
34
+ // artefact that did not exist, and `check` could not see it because the bytes were
35
+ // current.
36
+ //
37
+ // The alternative spelling does exist — prettier reads `quote_type` from a matching
38
+ // glob — and it is deliberately not used. Three of five repositories in this estate
39
+ // already state `singleQuote` and two do not, so a shared `quote_type = single`
40
+ // reformats every source file in those two on the day they adopt the tool. ADR-048
41
+ // carries the survey; the sentence is here because the next person who wants to settle a
42
+ // formatting argument in this file needs to know it was measured, not guessed at.
43
+
44
+ import { readFile } from 'node:fs/promises';
45
+ import { join } from 'node:path';
46
+
47
+ import { inject, parse } from './markers.mjs';
48
+ import { STYLES } from './markers.mjs';
49
+
50
+ /**
51
+ * The shared `.editorconfig` block.
52
+ *
53
+ * `root = true` is not decoration. An editorconfig search walks parent directories
54
+ * until it finds a `root = true`, so without it this repo's settings depend on
55
+ * whether something above it happens to have one — a baseline that changes meaning
56
+ * depending on where someone cloned it is not a baseline.
57
+ *
58
+ * The language sections carry only what a shared file can know: indentation, which is
59
+ * a property of the language and not of the project. Nothing here is a value any repo
60
+ * would want to argue with, which is the test it has to pass to belong in a file
61
+ * every repo has.
62
+ */
63
+ export const EDITORCONFIG_BODY = `root = true
64
+
65
+ [*]
66
+ charset = utf-8
67
+ insert_final_newline = true
68
+ trim_trailing_whitespace = true
69
+
70
+ [*.md]
71
+ # Two trailing spaces are a hard line break in Markdown. Stripping them there is not
72
+ # tidiness; it deletes meaning the author wrote.
73
+ trim_trailing_whitespace = false
74
+
75
+ [*.{js,mjs,cjs,jsx,ts,tsx,json,jsonc,css,scss,html,yml,yaml}]
76
+ indent_style = space
77
+ indent_size = 2
78
+
79
+ [*.{py,pyi,java,kt,kts}]
80
+ indent_size = 4
81
+
82
+ [{Makefile,*.mk}]
83
+ indent_style = tab
84
+
85
+ [{*.go,*.proto}]
86
+ indent_style = tab`;
87
+
88
+ /**
89
+ * The shared `.gitattributes` block.
90
+ *
91
+ * `text=auto` and not `text`. This is the whole content of the region, so it is worth
92
+ * saying why it is the cautious half of the choice: `* text eol=lf` forces git to treat
93
+ * every path as text and is the single most common way to corrupt a binary in a
94
+ * repository, because nothing tells git it is wrong. `auto` makes git decide per path
95
+ * from its own content sniffing, so a `.tif`, a `.zip` and a `.mc` file are left exactly
96
+ * as they are and only files that genuinely are text get normalised. One
97
+ * already worked this out on its own, which is the strongest evidence available that
98
+ * the conservative form is the one the estate would have picked.
99
+ *
100
+ * `eol=lf` is the claim ADR-025 deliberately refused to make from `.editorconfig`, and
101
+ * it is made here because this is where git reads it. The effect is on the working tree
102
+ * rather than on the editor, which is what makes it safe to share: a repo that has
103
+ * already committed CRLF gets LF on its next checkout, and nothing rewrites a file the
104
+ * moment the region lands.
105
+ *
106
+ * No `-text` on generated or vendored paths, no LFS filters, and no `linguist` or
107
+ * `diff` driver. Those are per-repo claims about what a file IS; a shared file that
108
+ * asserted them would be wrong in 13 of 16 repositories, and a wrong attribute is
109
+ * silently obeyed.
110
+ */
111
+ export const GITATTRIBUTES_BODY = `* text=auto eol=lf`;
112
+
113
+ /**
114
+ * Every baseline, as a row.
115
+ *
116
+ * `where` and `style` are the file's properties, not the caller's: both files here are
117
+ * last-definition-wins and both speak `#`, so the shared block goes first and a reader's
118
+ * own settings below it keep winning. `check` reports a region that has been moved down,
119
+ * because after the move it stops being true.
120
+ *
121
+ * @typedef {{file: string, style: string, body: () => string, why: string, adr: string}} Baseline
122
+ * @type {Baseline[]}
123
+ */
124
+ export const BASELINES = [
125
+ {
126
+ file: '.editorconfig',
127
+ style: 'hash',
128
+ body: () => EDITORCONFIG_BODY,
129
+ why: 'ten repos in the estate have no editorconfig at all, so two people opening the same file see two different files',
130
+ adr: 'ADR-025',
131
+ },
132
+ {
133
+ file: '.gitattributes',
134
+ style: 'hash',
135
+ body: () => GITATTRIBUTES_BODY,
136
+ why: 'line endings are the estate\'s to normalise and only git can normalise them; thirteen repos have no file that says so, and one asks for the forcing form that corrupts binaries',
137
+ adr: 'ADR-027',
138
+ },
139
+ ];
140
+
141
+ /**
142
+ * Build the write targets for every baseline.
143
+ *
144
+ * Returns the same shape `targetFor` returns for AGENTS.md, because `sync` writes one
145
+ * list and `check` compares against the same construction. A second shape here would
146
+ * be a second place computing the desired bytes, which is the defect that made the
147
+ * gate report drift on a clean tree before this list existed.
148
+ */
149
+ export async function baselineTargets(root, sha) {
150
+ const targets = [];
151
+ for (const b of BASELINES) {
152
+ const path = join(root, b.file);
153
+ let existing = '';
154
+ let exists = true;
155
+ try {
156
+ existing = await readFile(path, 'utf8');
157
+ } catch {
158
+ exists = false;
159
+ }
160
+ const body = b.body();
161
+ // No `sources` in the provenance header. It records where the bytes came from,
162
+ // and for a baseline the file being written IS the source - the module in front
163
+ // of the reader, not a path in a tree they do not have.
164
+ const result = inject(existing, body, { sha }, { style: b.style });
165
+ targets.push({
166
+ file: b.file,
167
+ kind: 'baseline',
168
+ style: b.style,
169
+ path,
170
+ exists,
171
+ action: result.action,
172
+ // As on the primary target: the state the file was in, for a caller that must
173
+ // distinguish "no region here" from "this file is small" (ADR-049, S26).
174
+ region: result.region,
175
+ previousBody: result.previousBody,
176
+ reason: result.reason,
177
+ line: result.line,
178
+ bytes: Buffer.byteLength(result.text),
179
+ previousBytes: Buffer.byteLength(existing),
180
+ sha,
181
+ body,
182
+ text: result.text,
183
+ previous: existing,
184
+ });
185
+ }
186
+ return { targets };
187
+ }
188
+
189
+ /**
190
+ * Why a baseline is not where this module would put it.
191
+ *
192
+ * A moved region is a silent behaviour change: nothing in the region changed, `check`
193
+ * would call it current, and the repo's own settings would simply have stopped
194
+ * applying. The bytes are right and the meaning is wrong, which is the hardest kind
195
+ * of drift to notice, so it gets its own finding rather than a note.
196
+ */
197
+ export function misplaced(region, style = 'hash') {
198
+ if (region.status !== 'ok') return null;
199
+ if (region.before.trim() === '') return null;
200
+ const s = STYLES[style] ?? STYLES.hash;
201
+ return {
202
+ detail:
203
+ 'the simt-dev region is not at the top of the file, so it overrides the settings ' +
204
+ 'below it; this format takes the last definition that matches, and the repo must win',
205
+ remedy: `move "${s.begin}" and "${s.end}" above your own settings, or delete the region and set what you need`,
206
+ };
207
+ }
208
+
209
+ export { parse };
@@ -0,0 +1,205 @@
1
+ // Detect hand-written rules that contradict a canonical ruling, before writing
2
+ // anything. Pure: it takes text, it never touches the filesystem.
3
+ //
4
+ // The distinction that matters here is conflict vs duplicate. A conflict is a rule
5
+ // that tells an agent to do something the canonical ruling forbids. A duplicate is
6
+ // the same rule restated in prose outside a generated region - prohibited by the
7
+ // anti-duplication doctrine, but not a contradiction.
8
+ //
9
+ // The distinction is not academic. One AGENTS.md says "Do not use `uv run
10
+ // pytest`" - which contains the forbidden string `uv run pytest` verbatim. A naive
11
+ // substring scan reports that file as violating the very rule it is correctly
12
+ // restating. So every match is checked for prohibition context first.
13
+
14
+ /** Files whose text is a hand-written rule surface. Scanned for conflicts only;
15
+ * generated regions are excluded at read time by the caller.
16
+ *
17
+ * `.cursor/rules/*.mdc` was NOT here for the whole life of the tool, and that is
18
+ * worth recording: the estate's three always-on `.mdc` families were the single
19
+ * worst-drifted content in it, and `simt-dev init` reported `CONFLICTS none detected`
20
+ * on the repo holding them. A conflict detector that only looks at the filenames
21
+ * it was written for will quietly miss a whole rule surface added later. */
22
+ export const RULE_FILES = ['AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md'];
23
+
24
+ /** Rule files discovered by glob rather than by name. */
25
+ export const RULE_GLOBS = ['.cursor/rules/*.mdc', '.github/instructions/*.md'];
26
+
27
+ /** Every hand-written rule path in a repo, named or globbed.
28
+ *
29
+ * The glob is split at its LAST `*`, and the two halves are used for what they
30
+ * actually mean: the part before it is the directory, the part after it is either
31
+ * a name prefix (`dir/*name.md`) or, when it starts with `.`, a file extension
32
+ * (`dir/*.mdc`). Matching a name prefix where an extension was meant finds
33
+ * nothing and reports no conflict — a detector that silently matches zero files
34
+ * is indistinguishable from a repo with no rules, which is exactly the failure
35
+ * this function exists to end. */
36
+ export async function rulePaths(root) {
37
+ const { readdir } = await import('node:fs/promises');
38
+ const { join } = await import('node:path');
39
+ const out = RULE_FILES.map((f) => join(root, f));
40
+ for (const glob of RULE_GLOBS) {
41
+ const star = glob.lastIndexOf('*');
42
+ const dir = join(root, glob.slice(0, star));
43
+ const tail = glob.slice(star + 1);
44
+ const suffix = tail.startsWith('.') ? tail : null;
45
+ const prefix = suffix === null ? tail : '';
46
+ let names;
47
+ try {
48
+ names = await readdir(dir);
49
+ } catch {
50
+ continue;
51
+ }
52
+ for (const name of names.sort()) {
53
+ if (suffix !== null ? name.endsWith(suffix) : name.startsWith(prefix) && name.endsWith('.md')) {
54
+ out.push(join(dir, name));
55
+ }
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const PROHIBITION = /\b(do not|do n't|don't|never|must not|mustn't|avoid|forbidden|prohibited|refuse)\b/i;
62
+
63
+ /** Markdown emphasis and code spans are stripped before the prohibition test, so
64
+ * `Do **not** use` reads as `Do not use` rather than as two unrelated words.
65
+ * CONTRIBUTING.md writes it the bold way, which is why this is not optional. */
66
+ function deMarkdown(text) {
67
+ return text.replace(/[*_`~]+/g, ' ').replace(/\s+/g, ' ');
68
+ }
69
+
70
+ /**
71
+ * Probes are ordered most-specific first. Each returns a match list; the caller
72
+ * classifies each hit by its surrounding context.
73
+ */
74
+ const PROBES = [
75
+ {
76
+ id: 'autonomous-push',
77
+ adr: 'ADR-001',
78
+ canonical: 'commit locally, report the SHA, push only on explicit approval',
79
+ detail:
80
+ 'asks for a push the agent performs without approval; the remote may be a production deploy',
81
+ // The suffix is optional on both words: garmin's DoD says "Committed and
82
+ // pushed", a different author wrote "Always commit and pushed", and an
83
+ // anchored pattern that only matched the first form reported a real
84
+ // autonomous-push rule as clean. `commit` also matches "commits" and
85
+ // "committed" because `commit?` only absorbs one `t`, not `ted`.
86
+ re: /\bcommit(?:te[ds]?|ting)?\s+and\s+push(?:e[ds]?|ing)?\b/gi,
87
+ // `push` with no suffix must match too: the group is optional, because an
88
+ // earlier version made it required and therefore only ever matched "pushed",
89
+ // reporting "commit and push without asking" as clean.
90
+ },
91
+ {
92
+ id: 'file-length-hard-split',
93
+ adr: 'ADR-005',
94
+ canonical: '400 lines is a review trigger, 1000 is the hard ceiling',
95
+ detail: 'restates one tier of the two-tier file-length ruling in prose',
96
+ // Two tiers must both be recognised: "split any file once it exceeds 1000
97
+ // lines" (the ceiling) and "keep every file under 400 lines" (the trigger).
98
+ // A repo restating either is not contradicting ADR-005 - the ruling was
99
+ // written so both positions survive as distinct tiers. It is a duplicate, and
100
+ // never a conflict, so this probe can only ever downgrade, never block.
101
+ duplicateOnly: true,
102
+ re: /\b(?:split (?:any )?file (?:once )?(?:it )?(?:exceeds|past)|file length:|files?\s+(?:under|below|over|max(?:imum)?\s+of))\s*\**(\d{3,4})\s*lines/gi,
103
+ },
104
+ {
105
+ id: 'package-runner',
106
+ adr: 'ADR-010',
107
+ canonical: 'invoke through the repository runner (npm run, uv run -m, ./gradlew), never a package runner',
108
+ detail: 'invokes a binary through a package runner, which resolves outside the project environment',
109
+ // `npx --no-install <pkg>` is exempt: it cannot fetch, so it cannot drift.
110
+ re: /\bnpx\s+(?!--no-install)(?:-[\w-]+\s+|[^\s-][\w@/.-]*)/g,
111
+ },
112
+ {
113
+ id: 'bare-console-entrypoint',
114
+ adr: 'ADR-010',
115
+ canonical: 'use the module form (uv run -m pytest), not the bare console entrypoint',
116
+ detail: 'the console entrypoint can miss the import path and silently run against the wrong environment',
117
+ re: /\buv run pytest\b(?! -m)/g,
118
+ },
119
+ {
120
+ id: 'lint-manually',
121
+ adr: 'ADR-004',
122
+ canonical: 'the hook owns format and lint for staged files',
123
+ detail: 'instructs a manual lint run that duplicates the hook',
124
+ re: /\brun\s+(?:ruff|eslint|lint|prettier|format)\b[^\n]{0,40}\b(?:by hand|manually)\b/gi,
125
+ },
126
+ {
127
+ id: 'full-suite-mandatory',
128
+ adr: 'ADR-003',
129
+ canonical: 'focused tests are the default; a full suite needs a named trigger',
130
+ detail: 'makes an unconditional full-suite run the gate, which ADR-003 reconciles into named triggers',
131
+ re: /\b(?:always run|must run|required to run) (?:the )?full (?:suite|gate|test)/gi,
132
+ },
133
+ ];
134
+
135
+ /**
136
+ * Scan one document's text.
137
+ * @param {string} text
138
+ * @param {string} file display path, used only in findings
139
+ * @returns {Array<{severity:'conflict'|'duplicate', id, adr, file, line, quote, detail, canonical}>}
140
+ */
141
+ export function scanDocument(text, file) {
142
+ const findings = [];
143
+ if (!text) return findings;
144
+
145
+ for (const probe of PROBES) {
146
+ probe.re.lastIndex = 0;
147
+ let match;
148
+ while ((match = probe.re.exec(text)) !== null) {
149
+ const quote = excerpt(text, match.index, match[0].length);
150
+ const context = contextBefore(text, match.index);
151
+ const negative = PROHIBITION.test(deMarkdown(context));
152
+
153
+ // No numeric guard: ADR-005 is two-tier and `duplicateOnly` already makes
154
+ // this probe incapable of producing a conflict. An earlier `n < 1000`
155
+ // guard looked harmless but silently dropped the entire 400-line tier.
156
+
157
+ findings.push({
158
+ severity: negative || probe.duplicateOnly === true ? 'duplicate' : 'conflict',
159
+ id: probe.id,
160
+ adr: probe.adr,
161
+ file,
162
+ line: lineOf(text, match.index),
163
+ quote,
164
+ detail: probe.detail,
165
+ canonical: probe.canonical,
166
+ });
167
+ }
168
+ }
169
+ return findings.sort((a, b) => a.line - b.line || a.id.localeCompare(b.id));
170
+ }
171
+
172
+ /** Summarise findings into the counts `init` needs to decide whether to proceed. */
173
+ export function summarise(findings) {
174
+ return {
175
+ total: findings.length,
176
+ conflicts: findings.filter((f) => f.severity === 'conflict').length,
177
+ duplicates: findings.filter((f) => f.severity === 'duplicate').length,
178
+ byAdr: [...new Set(findings.map((f) => f.adr))].sort(),
179
+ };
180
+ }
181
+
182
+ function lineOf(text, index) {
183
+ let line = 1;
184
+ for (let i = 0; i < index && i < text.length; i += 1) {
185
+ if (text[i] === '\n') line += 1;
186
+ }
187
+ return line;
188
+ }
189
+
190
+ /** The text immediately before a match, used to detect prohibition context.
191
+ * 160 chars is wide enough to carry "Do not use ..." or "never invoke ...", and
192
+ * narrow enough that an unrelated prohibition nearby cannot excuse the match. */
193
+ function contextBefore(text, index) {
194
+ return text.slice(Math.max(0, index - 160), index);
195
+ }
196
+
197
+ /** A single-line quote, so a finding can be pasted into a review comment. */
198
+ function excerpt(text, index, length, radius = 48) {
199
+ const from = Math.max(0, index - radius);
200
+ const to = Math.min(text.length, index + length + radius);
201
+ const slice = text.slice(from, to).replace(/\s+/g, ' ').trim();
202
+ return `${from > 0 ? '...' : ''}${slice}${to < text.length ? '...' : ''}`;
203
+ }
204
+
205
+ export const __internals = { PROBES, PROHIBITION };