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,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 };
|