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,504 @@
|
|
|
1
|
+
// Decide what a repo's generated region should contain.
|
|
2
|
+
//
|
|
3
|
+
// Two jobs, deliberately separate from writing (markers.mjs) and from the gate
|
|
4
|
+
// (commands/check):
|
|
5
|
+
// 1. plan(root) -> { targets: [{file, action, body, ...}], stack, rules }
|
|
6
|
+
// 2. Nothing here touches the filesystem. A plan is pure, so it can be
|
|
7
|
+
// inspected, diffed and asserted on without a scratch directory.
|
|
8
|
+
//
|
|
9
|
+
// The core rule set is always included. Stack profiles are selected by
|
|
10
|
+
// intersection with the repo's fingerprint - platform tags *and* emitted facts,
|
|
11
|
+
// which are interchangeable in a profile (ADR-013) - plus one fallback profile
|
|
12
|
+
// so a JS/TS repo is never left with core rules and nothing else.
|
|
13
|
+
//
|
|
14
|
+
// Profiles may live in the repo, not only here. `.simt-dev/profiles/*.md` uses
|
|
15
|
+
// the same frontmatter contract as `rules/stacks/*.md` and is selected by the
|
|
16
|
+
// same matcher, so supporting an ecosystem nobody here has heard of requires no
|
|
17
|
+
// code change and no release.
|
|
18
|
+
|
|
19
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { baselineTargets, misplaced } from './baselines.mjs';
|
|
23
|
+
import { planWorkflow } from '../ci/workflow.mjs';
|
|
24
|
+
import { planDependabot } from '../ci/dependabot.mjs';
|
|
25
|
+
import { checkTsconfig } from '../tsconfig.mjs';
|
|
26
|
+
import { checkToolchain } from '../toolchain.mjs';
|
|
27
|
+
import { diff, parse, inject, BEGIN, END } from './markers.mjs';
|
|
28
|
+
import { readManifest } from './manifest.mjs';
|
|
29
|
+
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
30
|
+
import { foldsRules, resolveSurfaces, SURFACES, SURFACE_IDS } from './surfaces.mjs';
|
|
31
|
+
import { pointerState, pointerTargets, ruleTargets } from './targets.mjs';
|
|
32
|
+
import { CLI } from '../name.mjs';
|
|
33
|
+
import { MANIFEST_FILE } from '../name.mjs';
|
|
34
|
+
import {
|
|
35
|
+
desiredRegion,
|
|
36
|
+
provenanceDigest,
|
|
37
|
+
isEligible,
|
|
38
|
+
loadRules,
|
|
39
|
+
LOCAL_PROFILE_DIR,
|
|
40
|
+
mdcTargets,
|
|
41
|
+
readDeclared,
|
|
42
|
+
ruleValues,
|
|
43
|
+
render,
|
|
44
|
+
ROOT,
|
|
45
|
+
select,
|
|
46
|
+
SUBSTITUTIONS_KEY,
|
|
47
|
+
} from './rules.mjs';
|
|
48
|
+
|
|
49
|
+
/** The one file every repo gets. Chosen deliberately: AGENTS.md is read by the
|
|
50
|
+
* most agents, including ones that ignore .cursor/ entirely. */
|
|
51
|
+
export const PRIMARY_TARGET = 'AGENTS.md';
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} root repo root
|
|
56
|
+
* @returns {Promise<{root:string, stack:string[], facts:string[], profiles:string[],
|
|
57
|
+
* rules:object[], targets:object[], notes:string[]}>}
|
|
58
|
+
*/
|
|
59
|
+
export async function plan(root, flags = {}) {
|
|
60
|
+
const desired = await desiredRegion(root, flags);
|
|
61
|
+
const { stack, facts, profiles, fallback, notes, body, meta, all, manifest } = desired;
|
|
62
|
+
const target = await targetFor(root, body, meta);
|
|
63
|
+
|
|
64
|
+
// Per-rule content projects to every editor the repo selected, not only Cursor. The
|
|
65
|
+
// body is identical on each; only the frontmatter the editor reads before any rule
|
|
66
|
+
// text differs. `rule.file` names the canonical source, not a destination — the
|
|
67
|
+
// destination belongs to the surface.
|
|
68
|
+
const mdc = await mdcTargets(root, meta.sha, manifest, flags);
|
|
69
|
+
|
|
70
|
+
const surfaces = resolveSurfaces(manifest, flags);
|
|
71
|
+
|
|
72
|
+
// The baselines are the same list shape as every other target, so `sync` writes
|
|
73
|
+
// them and reports them in the same plan it prints for AGENTS.md and the rule
|
|
74
|
+
// files. A file the reader was not shown is a file they approve by reading the diff
|
|
75
|
+
// afterwards, which is the one review step this tool exists to make unnecessary.
|
|
76
|
+
const baselines = await baselineTargets(root, meta.sha);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
root,
|
|
80
|
+
stack,
|
|
81
|
+
facts,
|
|
82
|
+
profiles,
|
|
83
|
+
fallback,
|
|
84
|
+
surfaces: surfaces.ids,
|
|
85
|
+
surfaceSource: surfaces.source,
|
|
86
|
+
surfaceProblems: surfaces.problems,
|
|
87
|
+
rules: all,
|
|
88
|
+
targets: [target, ...mdc.targets, ...baselines.targets],
|
|
89
|
+
mdcProblems: mdc.problems,
|
|
90
|
+
notes: [...notes, ...mdc.notes],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function targetFor(root, body, meta) {
|
|
95
|
+
const path = join(root, PRIMARY_TARGET);
|
|
96
|
+
let existing = '';
|
|
97
|
+
let exists = true;
|
|
98
|
+
try {
|
|
99
|
+
existing = await readFile(path, 'utf8');
|
|
100
|
+
} catch {
|
|
101
|
+
exists = false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = inject(existing, body, meta, await regionOpts(root));
|
|
105
|
+
return {
|
|
106
|
+
file: PRIMARY_TARGET,
|
|
107
|
+
path,
|
|
108
|
+
exists,
|
|
109
|
+
action: result.action,
|
|
110
|
+
// The state the file was in, rather than the action taken on it. `upgrade` needs to
|
|
111
|
+
// know whether this repository ever had a region (ADR-049's sibling defect, filed as
|
|
112
|
+
// S26) and whether the change it is about to make is a change to the *rules* or only
|
|
113
|
+
// to the seam around them, and neither is readable off `action` or `previousBytes`.
|
|
114
|
+
region: result.region,
|
|
115
|
+
previousBody: result.previousBody,
|
|
116
|
+
reason: result.reason,
|
|
117
|
+
line: result.line,
|
|
118
|
+
bytes: Buffer.byteLength(result.text),
|
|
119
|
+
previousBytes: Buffer.byteLength(existing),
|
|
120
|
+
sha: meta.sha,
|
|
121
|
+
body,
|
|
122
|
+
text: result.text,
|
|
123
|
+
previous: existing,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Check one repo without writing. Returns findings rather than an exit code,
|
|
128
|
+
* so commands/check decides severity and commands/sync decides whether to act. */
|
|
129
|
+
export async function checkTargets(root, flags = {}) {
|
|
130
|
+
const findings = [];
|
|
131
|
+
const primary = await checkPrimary(root, flags);
|
|
132
|
+
findings.push(primary);
|
|
133
|
+
if (primary.status !== 'absent' && primary.status !== 'unreadable' && primary.status !== 'malformed') {
|
|
134
|
+
let text = '';
|
|
135
|
+
try {
|
|
136
|
+
text = await readFile(join(root, PRIMARY_TARGET), 'utf8');
|
|
137
|
+
} catch {
|
|
138
|
+
text = '';
|
|
139
|
+
}
|
|
140
|
+
findings.push(...readLimitFindings(text, PRIMARY_TARGET));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The gate has to run where the merge happens, not only on the machine of whoever
|
|
144
|
+
// remembers to run it. A repo that already runs GitHub Actions and has no simt-dev
|
|
145
|
+
// job is ungated at its front door, and that is the one finding here that no amount
|
|
146
|
+
// of reading the local rules would surface.
|
|
147
|
+
// Two `.github` files, one ownership rule, so one loop over both. The gate and the
|
|
148
|
+
// dependency-updates config fail for the same reason and are fixed the same way,
|
|
149
|
+
// and listing them separately is how the second copy of this rule gets written.
|
|
150
|
+
for (const [plan, what] of [
|
|
151
|
+
[await planWorkflow(root), 'nothing gates it'],
|
|
152
|
+
[await planDependabot(root), 'nothing watches its dependencies'],
|
|
153
|
+
]) {
|
|
154
|
+
if (plan.action === 'create') {
|
|
155
|
+
findings.push({
|
|
156
|
+
file: plan.file,
|
|
157
|
+
status: 'absent',
|
|
158
|
+
detail: `this repo runs GitHub Actions but ${what}`,
|
|
159
|
+
remedy: `${CLI} init --yes`,
|
|
160
|
+
});
|
|
161
|
+
} else if (plan.action === 'refresh') {
|
|
162
|
+
// Only reachable in simt-dev's own repository, and only because these plans
|
|
163
|
+
// compare content there and nowhere else. A copy of the template that is
|
|
164
|
+
// allowed to drift stops being the template, so the drift is reported here
|
|
165
|
+
// rather than left for whoever next changes the template to notice by reading
|
|
166
|
+
// a diff.
|
|
167
|
+
findings.push({
|
|
168
|
+
file: plan.file,
|
|
169
|
+
status: 'drift',
|
|
170
|
+
detail: plan.reason,
|
|
171
|
+
remedy: `${CLI} ci --yes`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// TypeScript is the one baseline whose content a base file cannot enforce, because
|
|
177
|
+
// `extends` lets a child override every key it sets. So absence is reported from the
|
|
178
|
+
// plan like the `.github` files, and the two things a base file structurally cannot
|
|
179
|
+
// fix come from `checkTsconfig` with their own remedies.
|
|
180
|
+
findings.push(...(await checkTsconfig(root)));
|
|
181
|
+
|
|
182
|
+
// What the repository pins, read and never written: the version floor, the package
|
|
183
|
+
// manager and the `.nvmrc` are the repository's decisions, so every finding here names
|
|
184
|
+
// the command that records one rather than a value this tool would have had to invent.
|
|
185
|
+
findings.push(...(await checkToolchain(root)));
|
|
186
|
+
|
|
187
|
+
// A rule with a defect is a gate failure even when its files happen to be
|
|
188
|
+
// correct, because the next `sync` will not reproduce it.
|
|
189
|
+
// Selection must match `plan` exactly, or the gate reports a rule absent that
|
|
190
|
+
// `sync` was never going to write — a permanently red check nothing can fix.
|
|
191
|
+
const all = await loadMdcRules();
|
|
192
|
+
const { stack, facts, manifest } = await desiredRegion(root, flags);
|
|
193
|
+
const declared = await readDeclared(root);
|
|
194
|
+
const resolved = resolveSurfaces(manifest, flags);
|
|
195
|
+
for (const p of resolved.problems) {
|
|
196
|
+
findings.push({ file: `${MANIFEST_FILE}`, status: 'invalid', detail: p, remedy: `known surfaces: ${SURFACE_IDS.join(', ')}` });
|
|
197
|
+
}
|
|
198
|
+
const rules = all.filter((r) => isEligible(r, new Set([...stack, ...facts]), declared.optIn));
|
|
199
|
+
|
|
200
|
+
const sha = await provenanceDigest();
|
|
201
|
+
const notes = [];
|
|
202
|
+
|
|
203
|
+
for (const rule of rules) {
|
|
204
|
+
if (rule.error !== null) {
|
|
205
|
+
findings.push({
|
|
206
|
+
file: rule.id ?? rule.file,
|
|
207
|
+
status: 'invalid',
|
|
208
|
+
detail: rule.error,
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// The writer's own construction, not a second one: a body rendered without the
|
|
213
|
+
// surface-agreed substitutions would make `check` report drift on a tree that
|
|
214
|
+
// `init` just wrote (S45's askTool).
|
|
215
|
+
const { body, problems } = renderMdc(
|
|
216
|
+
rule,
|
|
217
|
+
ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
|
|
218
|
+
);
|
|
219
|
+
if (problems.length > 0) {
|
|
220
|
+
findings.push({
|
|
221
|
+
file: rule.target,
|
|
222
|
+
status: 'invalid',
|
|
223
|
+
detail: problems[0],
|
|
224
|
+
});
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const target of (await ruleTargets(root, rule, body, sha, resolved, notes)).targets) {
|
|
229
|
+
const rel = target.file;
|
|
230
|
+
if (!target.exists) {
|
|
231
|
+
findings.push({
|
|
232
|
+
file: rel,
|
|
233
|
+
surface: target.surface,
|
|
234
|
+
status: 'absent',
|
|
235
|
+
detail: `no ${target.surface} rule here; run: ${CLI} sync --yes`,
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const state = parse(target.previous);
|
|
240
|
+
if (state.status === 'malformed') {
|
|
241
|
+
findings.push({
|
|
242
|
+
file: rel,
|
|
243
|
+
surface: target.surface,
|
|
244
|
+
status: 'malformed',
|
|
245
|
+
detail: state.reason,
|
|
246
|
+
line: state.line,
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (state.status === 'absent') {
|
|
251
|
+
findings.push({
|
|
252
|
+
file: rel,
|
|
253
|
+
surface: target.surface,
|
|
254
|
+
status: 'unmanaged',
|
|
255
|
+
detail: `no generated region; run: ${CLI} sync --yes`,
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// `target.action` is `ruleTargets`' own verdict, produced by the same inject
|
|
260
|
+
// call `plan` will make. This used to reconstruct a reference here — existing
|
|
261
|
+
// frontmatter plus a fresh inject — which was a THIRD construction of the same
|
|
262
|
+
// file inside the one function whose entire job is to say whether the other two
|
|
263
|
+
// agree. It happened to agree, which is what makes this duplication dangerous:
|
|
264
|
+
// it is only detectable by the disagreement some future edit produces.
|
|
265
|
+
const action = target.action;
|
|
266
|
+
findings.push({
|
|
267
|
+
file: rel,
|
|
268
|
+
surface: target.surface,
|
|
269
|
+
status: action === 'unchanged' ? 'current' : 'drift',
|
|
270
|
+
detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
|
|
271
|
+
recordedSha: state.sha ?? null,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Baselines are checked against the same construction `sync` writes from, and they
|
|
277
|
+
// block for the same reason the rule files block: an adopting repo whose baseline was
|
|
278
|
+
// deleted or reverted is a repo whose editors have quietly stopped agreeing.
|
|
279
|
+
for (const t of (await baselineTargets(root, await provenanceDigest())).targets) {
|
|
280
|
+
if (t.action === 'malformed') {
|
|
281
|
+
findings.push({ file: t.file, status: 'malformed', detail: t.reason, line: t.line });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (!t.exists) {
|
|
285
|
+
findings.push({
|
|
286
|
+
file: t.file,
|
|
287
|
+
status: 'absent',
|
|
288
|
+
detail: `no baseline file; run: ${CLI} sync --yes`,
|
|
289
|
+
remedy: `${CLI} sync --yes`,
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const state = parse(t.previous, t.style);
|
|
294
|
+
if (state.status === 'absent') {
|
|
295
|
+
findings.push({
|
|
296
|
+
file: t.file,
|
|
297
|
+
status: 'unmanaged',
|
|
298
|
+
detail: `the file exists with no simt-dev region; run: ${CLI} sync --yes`,
|
|
299
|
+
remedy: `${CLI} sync --yes`,
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
findings.push({
|
|
304
|
+
file: t.file,
|
|
305
|
+
status: t.action === 'unchanged' ? 'current' : 'drift',
|
|
306
|
+
detail: t.action === 'unchanged' ? undefined : `region is ${t.action}; run: ${CLI} sync --yes`,
|
|
307
|
+
recordedSha: state.sha ?? null,
|
|
308
|
+
});
|
|
309
|
+
const off = misplaced(state, t.style);
|
|
310
|
+
if (off) findings.push({ file: t.file, status: 'misplaced', ...off });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Managed rule files this run would NOT write, found by scanning the rule
|
|
314
|
+
// directories rather than by planning them. Without this, narrowing a surface's
|
|
315
|
+
// output makes the files it already wrote invisible: measured on the estate, 17
|
|
316
|
+
// files across five repos that carry a simt-dev region and were counted as stale
|
|
317
|
+
// the moment the skip landed. A tool that stops looking at a file it generated has
|
|
318
|
+
// not cleaned up - it has lost track of it.
|
|
319
|
+
//
|
|
320
|
+
// Reported as `orphaned`, never `drift`. ADR-021 ruling 6: `sync` does not delete a
|
|
321
|
+
// file it once generated, so there is nothing for the reader to repair and a
|
|
322
|
+
// `run: simt-dev sync --yes` remedy would be a lie. Removal is a human decision.
|
|
323
|
+
findings.push(...(await orphanedRuleFiles(root, resolved)));
|
|
324
|
+
|
|
325
|
+
// Pointers are checked last, and whether one blocks depends on who reads it.
|
|
326
|
+
//
|
|
327
|
+
// The rule as it stood was "never block", and it was right for the case it was written
|
|
328
|
+
// for: a repository on `opencode` that happens to have a `CLAUDE.md` nobody asked for
|
|
329
|
+
// gains nothing from being told about it, and a red gate on every commit trains people
|
|
330
|
+
// to ignore gates. But the pointer is not decoration for the reader it serves —
|
|
331
|
+
// `POINTERS[0]` cites Claude Code's own documentation for the behaviour that makes it
|
|
332
|
+
// load-bearing: **"Claude reads CLAUDE.md by default and AGENTS.md only as a
|
|
333
|
+
// fallback."** So on a repo that selected the `claude` surface, a `CLAUDE.md` aimed
|
|
334
|
+
// anywhere else means Claude Code reads that somewhere else. Measured: the pointer
|
|
335
|
+
// moved to `README.md`, `check` exited 0, `compare` reported no problems, and thirteen
|
|
336
|
+
// rule citations went unread by the reader they were written for.
|
|
337
|
+
//
|
|
338
|
+
// Blocking is therefore scoped to a pointer whose surface this repo selected. An
|
|
339
|
+
// unselected one stays a note, because the reader it serves is not reading this repo.
|
|
340
|
+
for (const pointer of pointerTargets(root, resolved.surfaces)) {
|
|
341
|
+
const st = await pointerState(root, pointer);
|
|
342
|
+
if (st.state === 'foreign') {
|
|
343
|
+
findings.push({
|
|
344
|
+
file: st.file,
|
|
345
|
+
surface: st.surface,
|
|
346
|
+
status: 'pointer-blocked',
|
|
347
|
+
// A file reports its bytes because that is what the refusal would have
|
|
348
|
+
// destroyed; a directory reports what is inside it, which is the same question
|
|
349
|
+
// asked of the only thing a reader would lose. Neither is replaced — this tool
|
|
350
|
+
// does not delete what a person wrote — so both name the same remedy.
|
|
351
|
+
detail:
|
|
352
|
+
st.entries === undefined
|
|
353
|
+
? `${st.detail} (${st.bytes} bytes); simt-dev will not replace it`
|
|
354
|
+
: `${st.detail} (${st.entries} ${st.entries === 1 ? 'entry' : 'entries'}); simt-dev will not replace it`,
|
|
355
|
+
remedy: `move it aside, or delete the ${st.entries === undefined ? 'file' : 'directory'} and re-run: ${CLI} sync --yes`,
|
|
356
|
+
});
|
|
357
|
+
} else if (st.state === 'mispointed') {
|
|
358
|
+
// Whether this blocks is the reader's business, not the file's. `pointer-drift` is
|
|
359
|
+
// non-blocking because a reader who does not use this pointer gains nothing from a
|
|
360
|
+
// red gate; a reader who does gains a repository where its rules are one file away
|
|
361
|
+
// from being invisible, and `check` reporting nothing is what made that invisible.
|
|
362
|
+
//
|
|
363
|
+
// Every pointer reaching this loop belongs to a selected surface —
|
|
364
|
+
// `pointerTargets` filters on it — so the status is chosen from whether the
|
|
365
|
+
// pointer's own reader is one this repository serves, which `pointerTargets` has
|
|
366
|
+
// already answered by including it.
|
|
367
|
+
findings.push({
|
|
368
|
+
file: st.file,
|
|
369
|
+
surface: st.surface,
|
|
370
|
+
status: 'pointer-unread',
|
|
371
|
+
detail: `${st.detail}; should point at ${pointer.to}`,
|
|
372
|
+
remedy: `run: ${CLI} sync --yes (${pointer.readers} reads ${pointer.file}, not ${pointer.to})`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return findings;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Rule files carrying a simt-dev region that this run would not write.
|
|
382
|
+
*
|
|
383
|
+
* "Would not write" rather than "is not wanted": a repo that never adopted these
|
|
384
|
+
* surfaces has its own hand-written `.mdc` files, and those carry no region, so they
|
|
385
|
+
* are not ours to report. Only a file simt-dev generated qualifies.
|
|
386
|
+
*/
|
|
387
|
+
async function orphanedRuleFiles(root, resolved) {
|
|
388
|
+
const out = [];
|
|
389
|
+
if (!foldsRules(resolved)) return out;
|
|
390
|
+
const seen = new Set();
|
|
391
|
+
// Every surface that reads the primary, whether or not it is selected now.
|
|
392
|
+
// A file becomes orphaned precisely when the repo stops asking for the surface
|
|
393
|
+
// that used to write it, so restricting this to the SELECTED set finds nothing:
|
|
394
|
+
// the one repo that needs the note is the one that dropped the surface. Measured,
|
|
395
|
+
// that was every surface in the estate after the change.
|
|
396
|
+
for (const s of SURFACES.filter((x) => x.readsPrimary === true && x.rule)) {
|
|
397
|
+
const dir = s.rule.slice(0, s.rule.lastIndexOf('/'));
|
|
398
|
+
if (seen.has(dir)) continue;
|
|
399
|
+
seen.add(dir);
|
|
400
|
+
let entries;
|
|
401
|
+
try {
|
|
402
|
+
entries = await readdir(join(root, dir));
|
|
403
|
+
} catch {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
for (const name of entries.sort()) {
|
|
407
|
+
const rel = `${dir}/${name}`;
|
|
408
|
+
let text;
|
|
409
|
+
try {
|
|
410
|
+
text = await readFile(join(root, rel), 'utf8');
|
|
411
|
+
} catch {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
// Only a file this tool wrote. `parse` reports `absent` for anything without the
|
|
415
|
+
// marker, which is precisely the hand-written case that must stay untouched.
|
|
416
|
+
if (parse(text).status === 'absent') continue;
|
|
417
|
+
out.push({
|
|
418
|
+
file: rel,
|
|
419
|
+
surface: s.id,
|
|
420
|
+
status: 'orphaned',
|
|
421
|
+
detail: `${s.label} reads AGENTS.md, where this rule is already folded; this file repeats it`,
|
|
422
|
+
remedy: `delete ${rel}; simt-dev will not remove a file it wrote`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function checkTarget(root, flags = {}) {
|
|
430
|
+
const findings = await checkPrimary(root, flags);
|
|
431
|
+
return findings;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function checkPrimary(root, flags = {}) {
|
|
435
|
+
const { body, meta } = await desiredRegion(root, flags);
|
|
436
|
+
const path = join(root, PRIMARY_TARGET);
|
|
437
|
+
let existing = '';
|
|
438
|
+
try {
|
|
439
|
+
existing = await readFile(path, 'utf8');
|
|
440
|
+
} catch (e) {
|
|
441
|
+
// Only ENOENT is absence. A bare `catch` here once reported "no AGENTS.md" for a
|
|
442
|
+
// repo that has one, because the failure was a ReferenceError from a missing
|
|
443
|
+
// import — the gate answered confidently about a file it had never read. A check
|
|
444
|
+
// that is wrong about existence is worse than one that crashes.
|
|
445
|
+
if (e.code !== 'ENOENT') {
|
|
446
|
+
return { file: PRIMARY_TARGET, status: 'unreadable', detail: e.code ?? String(e) };
|
|
447
|
+
}
|
|
448
|
+
return { file: PRIMARY_TARGET, status: 'absent', detail: `no AGENTS.md; run: ${CLI} sync --yes` };
|
|
449
|
+
}
|
|
450
|
+
const state = parse(existing);
|
|
451
|
+
if (state.status === 'malformed') {
|
|
452
|
+
return {
|
|
453
|
+
file: PRIMARY_TARGET,
|
|
454
|
+
status: 'malformed',
|
|
455
|
+
detail: state.reason,
|
|
456
|
+
line: state.line,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
if (state.status === 'absent') {
|
|
460
|
+
return { file: PRIMARY_TARGET, status: 'unmanaged', detail: `no generated region; run: ${CLI} sync --yes` };
|
|
461
|
+
}
|
|
462
|
+
const d = diff(existing, body, meta, await regionOpts(root));
|
|
463
|
+
return {
|
|
464
|
+
file: PRIMARY_TARGET,
|
|
465
|
+
status: d.action === 'unchanged' ? 'current' : 'drift',
|
|
466
|
+
detail: d.action === 'unchanged' ? undefined : `region is ${d.action}; run: ${CLI} sync --yes`,
|
|
467
|
+
recordedSha: state.sha ?? null,
|
|
468
|
+
expectedSha: meta.sha,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/** `projection.place: "top"` puts the AGENTS.md region above the hand-written prefix. */
|
|
472
|
+
async function regionOpts(root) {
|
|
473
|
+
const { manifest } = await readManifest(root);
|
|
474
|
+
return manifest?.projection?.place === 'top' ? { where: 'top' } : {};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* A reader that stops at a byte offset never sees a region that ends past it.
|
|
479
|
+
* The finding names the reader, because "the file is long" does not say who stops.
|
|
480
|
+
*/
|
|
481
|
+
export function readLimitFindings(text, file) {
|
|
482
|
+
const state = parse(text);
|
|
483
|
+
if (state.status !== 'ok' || typeof state.endByte !== 'number') return [];
|
|
484
|
+
const findings = [];
|
|
485
|
+
for (const surface of SURFACES) {
|
|
486
|
+
if (surface.primary !== file) continue;
|
|
487
|
+
for (const limit of surface.readLimits ?? []) {
|
|
488
|
+
if (state.endByte <= limit.bytes) continue;
|
|
489
|
+
findings.push({
|
|
490
|
+
file,
|
|
491
|
+
status: 'beyond-read-limit',
|
|
492
|
+
detail: `BEYOND-READ-LIMIT: the region ends at byte ${state.endByte}, past ${limit.reader}'s ${limit.bytes}-byte read of ${file}`,
|
|
493
|
+
remedy: `set projection.place to "top" in ${MANIFEST_FILE} and run: ${CLI} sync --yes, or raise ${limit.reader}'s project_doc_max_bytes`,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return findings;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export { BEGIN, END };
|
|
501
|
+
// Re-exported so callers import the projection surface from one place; the pointer
|
|
502
|
+
// helpers now live in targets.mjs and two commands already reach for them here.
|
|
503
|
+
export { pointerTargets, pointerState };
|
|
504
|
+
export { provenanceDigest, LOCAL_PROFILE_DIR, loadRules, render, select, SUBSTITUTIONS_KEY };
|