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,398 @@
|
|
|
1
|
+
// Which editor a repo's rules are projected into, and why.
|
|
2
|
+
//
|
|
3
|
+
// simt-dev used to write exactly two things into a consumer repo: a region in
|
|
4
|
+
// `AGENTS.md`, and `.cursor/rules/*.mdc`. That is support for two IDEs, one of
|
|
5
|
+
// which is the only reader of the `.mdc` extension. A Claude Code user, a Copilot
|
|
6
|
+
// user and an Antigravity user in an adopted repo received `AGENTS.md` — the core
|
|
7
|
+
// and stack rules — and **none** of the three always-on process rules, because
|
|
8
|
+
// those lived only in a directory only Cursor reads. "Supported" was true of the
|
|
9
|
+
// file format and false of the estate.
|
|
10
|
+
//
|
|
11
|
+
// This module is the named surface: each row is one editor, the path its rules
|
|
12
|
+
// take, the frontmatter keys it understands, and the document the row was checked
|
|
13
|
+
// against. Nothing here is inferred from the ecosystem; a surface is a fact about
|
|
14
|
+
// a vendor, and it was read, not guessed.
|
|
15
|
+
//
|
|
16
|
+
// FOUR DECISIONS, each with a cost.
|
|
17
|
+
//
|
|
18
|
+
// 1. A POINTER IS NOT A COPY. `CLAUDE.md` and `GEMINI.md` are symlinks to
|
|
19
|
+
// `AGENTS.md`. A copy is the corpus's one true content duplicate
|
|
20
|
+
// (one repository's `CLAUDE.md` is byte-identical to its `AGENTS.md`), and
|
|
21
|
+
// the DeepSeek harness explicitly rejects loading both in one directory
|
|
22
|
+
// because "repositories in transition commonly duplicate guidance" — and
|
|
23
|
+
// *its* dedup is content-based, so a symlink collapses to one while a
|
|
24
|
+
// drifted copy loads in full beside its twin. Both vendors document the
|
|
25
|
+
// symlink. POSIX-only symlinks are already in scope under ADR-012.
|
|
26
|
+
//
|
|
27
|
+
// 2. THE PER-RULE SURFACES ARE GENERATED FILES, NOT SYMLINKS, because symlink
|
|
28
|
+
// support is documented for only one of them. Claude Code states that
|
|
29
|
+
// `.claude/rules/` supports symlinks. Nothing states that for
|
|
30
|
+
// `.agents/rules/` or `.github/instructions/`, and a symlink into a
|
|
31
|
+
// directory whose reader's symlink behaviour is unverified is a guess with a
|
|
32
|
+
// mode bit. So those get machine-written copies — and because every byte is
|
|
33
|
+
// derived from `rules/mdc/*.mdc` and `simt-dev check` compares each against
|
|
34
|
+
// that same derivation, they are not duplication, they are a build output.
|
|
35
|
+
// The difference from that duplicate is that nobody can hand-edit one
|
|
36
|
+
// and have it survive a sync.
|
|
37
|
+
//
|
|
38
|
+
// 3. AN ALWAYS-ON RULE EMITS NO SCOPING FRONTMATTER. Claude Code's `paths:` key
|
|
39
|
+
// has a documented parser defect — quoted values and YAML sequences
|
|
40
|
+
// silently fail to lazy-load (anthropics/claude-code#13905, #17204,
|
|
41
|
+
// #19377), and the documented examples are not valid YAML. Every rule
|
|
42
|
+
// shipped here is always-on, so the key would buy nothing and cost a
|
|
43
|
+
// silent-failure surface. Absence of frontmatter is documented to load
|
|
44
|
+
// eagerly, which is exactly what is wanted.
|
|
45
|
+
//
|
|
46
|
+
// 4. `verified` IS THE POINT OF THE TABLE. A row whose `verified` is null was
|
|
47
|
+
// not checked against a vendor document, and the row says so where a reader
|
|
48
|
+
// can see it. A surface list that silently grew a guessed row would put an
|
|
49
|
+
// unverified assumption into every repo that adopted it, which is the exact
|
|
50
|
+
// failure this table exists to prevent.
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Surfaces, in the order the writer emits them.
|
|
54
|
+
*
|
|
55
|
+
* `kind`:
|
|
56
|
+
* - `region` a generated region inside a file the repo may also write by hand
|
|
57
|
+
* - `file` a wholly generated file; hand-written body text is retired
|
|
58
|
+
* - `pointer` a symlink to the primary target; carries no content of its own
|
|
59
|
+
*
|
|
60
|
+
* `ruleFrontmatter` returns the keys that editor reads BEFORE any rule text. A
|
|
61
|
+
* surface that needs none returns '' and the projected file starts at the body.
|
|
62
|
+
*/
|
|
63
|
+
export const SURFACES = [
|
|
64
|
+
{
|
|
65
|
+
// THE STANDARD SURFACE. One repo, one editor, one file — unless the agent or the
|
|
66
|
+
// repo's owner names another. This row exists because "write a file for every IDE
|
|
67
|
+
// we know" is not support: it gives a repo that lives in opencode three rule
|
|
68
|
+
// directories and two symlinks it will never open, and it means every rule change
|
|
69
|
+
// lands in four places instead of one. ADR-020 wrote all four; ADR-021 writes one
|
|
70
|
+
// and asks.
|
|
71
|
+
id: 'opencode',
|
|
72
|
+
label: 'opencode (AGENTS.md + .agents/skills)',
|
|
73
|
+
readers:
|
|
74
|
+
'opencode, and AGENTS.md is also read natively by Codex, Cursor, Windsurf, Zed, Amp, Jules, Copilot CLI and the DeepSeek harness; Claude Code falls back to it, Antigravity and Gemini CLI accept GEMINI.md as an equal name',
|
|
75
|
+
primary: 'AGENTS.md',
|
|
76
|
+
kind: 'region',
|
|
77
|
+
// opencode documents NO per-rule directory: it reads AGENTS.md (walking up to the
|
|
78
|
+
// git worktree), CLAUDE.md only as a fallback, and .opencode/AGENTS.md in
|
|
79
|
+
// addition. So `foldRules` — this surface carries every rule inside the one
|
|
80
|
+
// generated region, and the other rows are how a rule reaches an editor that
|
|
81
|
+
// cannot read AGENTS.md. Writing .opencode/rules/*.md would be a guess, and a
|
|
82
|
+
// guessed path is the failure this table exists to prevent.
|
|
83
|
+
rule: null,
|
|
84
|
+
foldRules: true,
|
|
85
|
+
skills: '.agents/skills',
|
|
86
|
+
documentedSkillsPaths: ['.opencode/skills', '.claude/skills', '.agents/skills'],
|
|
87
|
+
// Codex concatenates AGENTS.md from the root down and stops at
|
|
88
|
+
// project_doc_max_bytes. The default is 32 KiB. A region that ends past it is
|
|
89
|
+
// never read, and nothing else in check notices. Other readers of this file
|
|
90
|
+
// publish no such limit.
|
|
91
|
+
readLimits: [
|
|
92
|
+
{
|
|
93
|
+
reader: 'Codex',
|
|
94
|
+
bytes: 32768,
|
|
95
|
+
verified: 'Codex project_doc_max_bytes defaults to 32 KiB',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
verified:
|
|
99
|
+
'opencode docs, "Rules" and "Agent Skills" (read 2026-10-07) — AGENTS.md walked up from cwd to the git worktree; CLAUDE.md used only if no AGENTS.md exists; .opencode/AGENTS.md additionally; skills at .opencode/skills/, .claude/skills/ and .agents/skills/<name>/SKILL.md; skill frontmatter recognises only name, description, license, compatibility, metadata',
|
|
100
|
+
note:
|
|
101
|
+
'.agents/skills is one of the three skill locations opencode documents, so the same directory serves the standard surface without a symlink.',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'cursor',
|
|
105
|
+
label: 'Cursor rules',
|
|
106
|
+
readers: 'Cursor',
|
|
107
|
+
primary: null,
|
|
108
|
+
kind: 'file',
|
|
109
|
+
rule: '.cursor/rules/{name}.mdc',
|
|
110
|
+
// `documentedKeys` is the set the vendor's own docs name for this surface, and
|
|
111
|
+
// `ruleFrontmatter` must emit nothing outside it. A key invented here would be
|
|
112
|
+
// inert at best; on Antigravity an unrecognised value silently discards the
|
|
113
|
+
// rule. The guard lives in test/shipped.test.mjs, and this row is its evidence.
|
|
114
|
+
documentedKeys: ['description', 'alwaysApply', 'globs'],
|
|
115
|
+
// ADR-021 ruled that "a Cursor reader getting .mdc files does not serve them",
|
|
116
|
+
// because Cursor had no documented channel for AGENTS.md. It does, and has:
|
|
117
|
+
// cursor.com/docs/context/rules calls AGENTS.md "a simple alternative to
|
|
118
|
+
// .cursor/rules" and documents support in the project root and subdirectories.
|
|
119
|
+
// So a folded rule reaches a Cursor reader twice - once inside the region, once
|
|
120
|
+
// in the .mdc - and the second copy is 28 KB of pure repetition across the five
|
|
121
|
+
// repos that select both surfaces.
|
|
122
|
+
//
|
|
123
|
+
// The caveat belongs here rather than in the ADR body, because it is a fact about
|
|
124
|
+
// a product and products move: a Cursor bug report records AGENTS.md NOT being
|
|
125
|
+
// read in worktree agents by default, fixed in 3.9. A reader below that version
|
|
126
|
+
// gets the .mdc copy and a reader above it does not need it.
|
|
127
|
+
readsPrimary: true,
|
|
128
|
+
// Filled into a substitution only when every selected surface names the same
|
|
129
|
+
// value. One body is written for all of them, so a Cursor name must not land
|
|
130
|
+
// in a file Claude Code also reads.
|
|
131
|
+
substitutions: { askTool: 'Use the interactive `AskQuestion` tool' },
|
|
132
|
+
ruleFrontmatter: (rule) =>
|
|
133
|
+
[
|
|
134
|
+
`description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
|
|
135
|
+
`alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
|
|
136
|
+
],
|
|
137
|
+
verified: 'Cursor project rules (.cursor/rules/*.mdc, description + alwaysApply + globs)',
|
|
138
|
+
note: 'The only surface that reads `.mdc`. Every other row here is a plain `.md` or a symlink.',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'claude',
|
|
142
|
+
label: 'Claude Code rules',
|
|
143
|
+
readers: 'Claude Code',
|
|
144
|
+
primary: null,
|
|
145
|
+
kind: 'file',
|
|
146
|
+
rule: '.claude/rules/{name}.md',
|
|
147
|
+
// Intentionally no `paths:`. See decision 3 in the header.
|
|
148
|
+
documentedKeys: ['paths', 'description'],
|
|
149
|
+
ruleFrontmatter: () => [],
|
|
150
|
+
// The claude-md pointer makes CLAUDE.md a symlink to AGENTS.md (ADR-021 ruling 1),
|
|
151
|
+
// and Claude reads CLAUDE.md in preference to AGENTS.md. So the folded region
|
|
152
|
+
// arrives through the pointer and a rule file alongside it is the same text twice.
|
|
153
|
+
readsPrimary: true,
|
|
154
|
+
substitutions: { askTool: 'Use the interactive `AskUserQuestion` tool' },
|
|
155
|
+
verified: 'Claude Code, "Organize rules with .claude/rules/" — recursive discovery, symlinks supported, no `paths` loads at session start',
|
|
156
|
+
note:
|
|
157
|
+
'CLAUDE.md is a separate pointer surface (see POINTERS). A rule file without frontmatter loads eagerly, which is what an always-on rule wants.',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'antigravity',
|
|
161
|
+
label: 'Antigravity workspace rules',
|
|
162
|
+
readers: 'Antigravity IDE, Antigravity CLI',
|
|
163
|
+
primary: null,
|
|
164
|
+
kind: 'file',
|
|
165
|
+
rule: '.agents/rules/{name}.md',
|
|
166
|
+
// `trigger` is REQUIRED here and an unrecognised value discards the rule with no
|
|
167
|
+
// message, which is why the value is mapped rather than passed through.
|
|
168
|
+
documentedKeys: ['trigger', 'description', 'globs', 'glob'],
|
|
169
|
+
// The gemini-md pointer's own `verified` cites the same Antigravity docs: "workspace
|
|
170
|
+
// rules are AGENTS.md or GEMINI.md in the root or any subdirectory". Either name
|
|
171
|
+
// reaches the reader, so a .agents/rules file repeats text the region already
|
|
172
|
+
// carries. Both channels are documented as alternatives to each other, which is
|
|
173
|
+
// what makes them duplicates rather than a fallback.
|
|
174
|
+
readsPrimary: true,
|
|
175
|
+
ruleFrontmatter: (rule) => [
|
|
176
|
+
`trigger: ${rule.alwaysApply ? 'always_on' : 'model_decision'}`,
|
|
177
|
+
`description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
|
|
178
|
+
],
|
|
179
|
+
verified: 'Google Antigravity docs, "Rules" — .agents/rules/*.md, YAML frontmatter with trigger: always_on | model_decision | glob | manual; missing or invalid frontmatter silently discards the rule',
|
|
180
|
+
note:
|
|
181
|
+
'This is why frontmatter is mandatory here and why the trigger value is not guessed: an unrecognised trigger means the rule is dropped with no message.',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'copilot',
|
|
185
|
+
label: 'GitHub Copilot instructions',
|
|
186
|
+
readers: 'GitHub Copilot chat, Copilot CLI, Copilot code review, Copilot cloud agent',
|
|
187
|
+
primary: null,
|
|
188
|
+
kind: 'file',
|
|
189
|
+
rule: '.github/instructions/{name}.instructions.md',
|
|
190
|
+
documentedKeys: ['applyTo', 'excludeAgent'],
|
|
191
|
+
ruleFrontmatter: () => ['applyTo: "**"'],
|
|
192
|
+
verified:
|
|
193
|
+
'GitHub docs, "Adding repository custom instructions" — .github/instructions/NAME.instructions.md with applyTo globs; `applyTo: "**"` is the documented example',
|
|
194
|
+
// NOT marked readsPrimary, and this row is the reason the flag is per-surface
|
|
195
|
+
// rather than global. The reader is split: Copilot CLI reads AGENTS.md, the
|
|
196
|
+
// editor and code review do not. A per-rule file here is not duplication for two
|
|
197
|
+
// of the three, and dropping it would remove the process rules from a chat that
|
|
198
|
+
// had no other channel to them.
|
|
199
|
+
note:
|
|
200
|
+
'Copilot reads AGENTS.md itself through Copilot CLI, but the editor and code review do not; this is the channel they use.',
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Pointers: files that must exist and must carry no content.
|
|
206
|
+
*
|
|
207
|
+
* A pointer is the cheapest possible answer to an editor that wants its own
|
|
208
|
+
* filename, and the only one that cannot rot: a stale pointer is a broken symlink
|
|
209
|
+
* that fails loudly, whereas a stale copy is a file full of confidently wrong
|
|
210
|
+
* rules that nobody notices for a release.
|
|
211
|
+
*/
|
|
212
|
+
export const POINTERS = [
|
|
213
|
+
{
|
|
214
|
+
id: 'claude-md',
|
|
215
|
+
// A pointer belongs to the surface that needs it, never to every repo.
|
|
216
|
+
// ADR-020 wrote both of these unconditionally, which meant that narrowing to
|
|
217
|
+
// one surface under ADR-021 still created a CLAUDE.md nobody asked for — the
|
|
218
|
+
// same over-writing that ruling set out to stop, one file down.
|
|
219
|
+
surface: 'claude',
|
|
220
|
+
path: 'CLAUDE.md',
|
|
221
|
+
to: 'AGENTS.md',
|
|
222
|
+
readers: 'Claude Code',
|
|
223
|
+
verified:
|
|
224
|
+
'Claude Code memory docs: "A symlink also works" — `ln -s AGENTS.md CLAUDE.md`; Claude reads CLAUDE.md by default and AGENTS.md only as a fallback',
|
|
225
|
+
note:
|
|
226
|
+
'Claude Code reads CLAUDE.md and ignores AGENTS.md when both exist. A pointer is what makes one file answer for both without a second copy.',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 'gemini-md',
|
|
230
|
+
surface: 'antigravity',
|
|
231
|
+
path: 'GEMINI.md',
|
|
232
|
+
to: 'AGENTS.md',
|
|
233
|
+
readers: 'Gemini CLI, Antigravity',
|
|
234
|
+
verified:
|
|
235
|
+
'Google Antigravity docs, "Rules": workspace rules are AGENTS.md or GEMINI.md in the root or any subdirectory; Copilot CLI also discovers GEMINI.md',
|
|
236
|
+
note: 'Covers Gemini CLI and gives Antigravity a second, equally-native name for the same file.',
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The global standard: what a repo gets when it has not chosen.
|
|
242
|
+
*
|
|
243
|
+
* ONE surface, not all of them. ADR-020 made the default the union of every
|
|
244
|
+
* verified surface, on the reasoning that writing nothing leaves editors
|
|
245
|
+
* unsupported. It over-corrected: a repo that lives in one editor acquired four
|
|
246
|
+
* rule directories and two symlinks, and every rule change landed four times.
|
|
247
|
+
* "Support every known editor" is satisfied by *knowing how to* write each one and
|
|
248
|
+
* writing the one that was asked for.
|
|
249
|
+
*
|
|
250
|
+
* The standard is opencode, because it is the agent simt-dev is developed against
|
|
251
|
+
* and its paths (`AGENTS.md`, `.agents/skills/`) are the ones this workspace already
|
|
252
|
+
* uses. `AGENTS.md` is the widest-reaching filename in the table: seven of the eight
|
|
253
|
+
* readers listed on that row read it natively, so the default is not a private
|
|
254
|
+
* choice — it is the file most editors already read.
|
|
255
|
+
*
|
|
256
|
+
* A repo narrows or changes it by declaring `"surfaces"` in simt-dev.json, and the
|
|
257
|
+
* agent can be asked for a specific one with `--surfaces`. Declaring an empty list is
|
|
258
|
+
* a real choice and means AGENTS.md with no per-rule files and no pointers.
|
|
259
|
+
*/
|
|
260
|
+
export const DEFAULT_SURFACES = ['opencode'];
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* True when every selected surface carries its rules inside the primary region
|
|
264
|
+
* rather than in per-rule files.
|
|
265
|
+
*
|
|
266
|
+
* ANY selected surface that folds is enough. Asking "does the whole set fold?" would
|
|
267
|
+
* be wrong the moment a repo adds a second surface: opencode's only channel is
|
|
268
|
+
* AGENTS.md, so a reader in opencode still needs the rules in the region even when a
|
|
269
|
+
* Cursor reader gets them as `.mdc`. Folding and per-rule files therefore coexist,
|
|
270
|
+
* and `ruleTargets` already skips a surface that has no rule path.
|
|
271
|
+
*
|
|
272
|
+
* Asked in exactly two places that must agree: `desiredRegion` folds the bodies in,
|
|
273
|
+
* and `mdcTargets` writes the files. Asking it twice is how ADR-020's fold decision
|
|
274
|
+
* could drift between the writer and the gate, which is the step-6 failure.
|
|
275
|
+
*/
|
|
276
|
+
export function foldsRules(resolved) {
|
|
277
|
+
return resolved.surfaces.some((s) => s.foldRules === true);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Surfaces whose reader already loads the primary document, so a per-rule file for
|
|
282
|
+
* them repeats text the region already carries.
|
|
283
|
+
*
|
|
284
|
+
* Only meaningful when folding is on: with no folding there is no region to duplicate.
|
|
285
|
+
* That condition is why this is not a property of the surface alone, and why the
|
|
286
|
+
* callers ask both questions rather than this function answering both.
|
|
287
|
+
*
|
|
288
|
+
* Per-surface rather than global, because Copilot is the counter-example: its CLI reads
|
|
289
|
+
* AGENTS.md and its editor and code review do not, so a per-rule file there is the only
|
|
290
|
+
* channel two of its three readers have.
|
|
291
|
+
*/
|
|
292
|
+
export function readsPrimary(resolved) {
|
|
293
|
+
return foldsRules(resolved) && resolved.surfaces.some((s) => s.readsPrimary === true);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const SURFACE_IDS = SURFACES.map((s) => s.id);
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Frontmatter keys a surface emits that its own vendor does not document.
|
|
300
|
+
*
|
|
301
|
+
* Returns one message per offender. Lives here rather than in a test so that the
|
|
302
|
+
* shipped-content gate and the surface table cannot drift apart: the gate asks this
|
|
303
|
+
* question, and so can anyone who wants to add a row.
|
|
304
|
+
*
|
|
305
|
+
* Two failure modes it must catch, both silent at the far end. A key an editor does
|
|
306
|
+
* not read is inert — the rule loads with its scoping ignored. A *value* an editor
|
|
307
|
+
* does not recognise is worse: Antigravity's documented behaviour for an
|
|
308
|
+
* unrecognised `trigger` is to discard the rule with no message, so the repo looks
|
|
309
|
+
* configured and is not.
|
|
310
|
+
*/
|
|
311
|
+
export function undocumentedKeys(surfaceRows = SURFACES) {
|
|
312
|
+
const bad = [];
|
|
313
|
+
for (const s of surfaceRows) {
|
|
314
|
+
if (!s.ruleFrontmatter) continue;
|
|
315
|
+
const documented = Array.isArray(s.documentedKeys) ? s.documentedKeys : [];
|
|
316
|
+
for (const line of s.ruleFrontmatter({ id: 'probe', description: 'probe', alwaysApply: true })) {
|
|
317
|
+
const key = String(line).split(':')[0].trim();
|
|
318
|
+
if (!documented.includes(key)) {
|
|
319
|
+
bad.push(`${s.id} emits frontmatter key "${key}"; its vendor documents ${documented.join(', ') || '(nothing)'}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return bad;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Surface `id` or null. */
|
|
327
|
+
export function surface(id) {
|
|
328
|
+
return SURFACES.find((s) => s.id === id) ?? null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The absolute-ish path a rule takes on a surface, given the rule's bare name. */
|
|
332
|
+
/** Assemble YAML frontmatter lines into a block. Lives here because this is the
|
|
333
|
+
* lowest module that owns frontmatter: surfaces.mjs imports nothing, so both
|
|
334
|
+
* the `.mdc` renderer and the per-surface projector can share one implementation
|
|
335
|
+
* without either importing the other. Two copies of these four lines is the
|
|
336
|
+
* "third copy of the same logic is a defect" case with a third-copy count of two. */
|
|
337
|
+
export function frontmatterBlock(lines) {
|
|
338
|
+
if (lines.length === 0) return '';
|
|
339
|
+
return ['---', ...lines, '---'].join('\n');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function rulePath(surfaceId, name) {
|
|
343
|
+
const s = surface(surfaceId);
|
|
344
|
+
if (!s || !s.rule) return null;
|
|
345
|
+
return s.rule.replace('{name}', name);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Which surfaces this repo projects to, and why it chose them.
|
|
350
|
+
*
|
|
351
|
+
* Precedence is flag, then manifest, then the global standard. Reported as
|
|
352
|
+
* `source` because an invisible configuration is a configuration nobody can
|
|
353
|
+
* debug — the same ruling as ADR-014 for the workspace.
|
|
354
|
+
*
|
|
355
|
+
* An unknown id is reported rather than skipped: a typo in `"surfaces"` would
|
|
356
|
+
* otherwise silently reduce the estate to AGENTS.md, and a repo that believes it
|
|
357
|
+
* is covered by Copilot instructions would not be.
|
|
358
|
+
*/
|
|
359
|
+
export function resolveSurfaces(manifest, flags = {}) {
|
|
360
|
+
const problems = [];
|
|
361
|
+
let ids = null;
|
|
362
|
+
let source = 'default';
|
|
363
|
+
|
|
364
|
+
if (typeof flags.surfaces === 'string') {
|
|
365
|
+
ids = flags.surfaces.split(',').map((s) => s.trim()).filter(Boolean);
|
|
366
|
+
source = 'flag';
|
|
367
|
+
} else if (manifest && Array.isArray(manifest.surfaces)) {
|
|
368
|
+
ids = manifest.surfaces.filter((s) => typeof s === 'string');
|
|
369
|
+
source = 'manifest';
|
|
370
|
+
} else {
|
|
371
|
+
ids = [...DEFAULT_SURFACES];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const seen = new Set();
|
|
375
|
+
const chosen = [];
|
|
376
|
+
for (const id of ids) {
|
|
377
|
+
if (!surface(id)) {
|
|
378
|
+
problems.push(`unknown surface "${id}"; known: ${SURFACE_IDS.join(', ')}`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (seen.has(id)) continue;
|
|
382
|
+
seen.add(id);
|
|
383
|
+
chosen.push(surface(id));
|
|
384
|
+
}
|
|
385
|
+
return { surfaces: chosen, ids: chosen.map((s) => s.id), source, problems };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One line a reader can check: what this repo gets, and why.
|
|
389
|
+
*
|
|
390
|
+
* `width` exists because the two renderers disagree — `init` pads its header to 12
|
|
391
|
+
* columns and `sync` to 8 — and a value column that does not line up with the
|
|
392
|
+
* lines above it reads as a different kind of thing, which is exactly the kind of
|
|
393
|
+
* small wrongness that makes people stop reading output carefully. */
|
|
394
|
+
export function surfacesLine(result, width = 12) {
|
|
395
|
+
const where = result.source === 'default' ? 'global standard' : result.source;
|
|
396
|
+
const ids = result.ids.length === 0 ? '(AGENTS.md only)' : result.ids.join(', ');
|
|
397
|
+
return `${'surfaces'.padEnd(width)}${ids} (${where})`;
|
|
398
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Where a rule actually lands: one body, many surfaces, plus the pointer files.
|
|
2
|
+
//
|
|
3
|
+
// Split out of project.mjs, which had grown past the 400-line review trigger in
|
|
4
|
+
// ADR-005 because this concern arrived with surface support (ADR-020) and had
|
|
5
|
+
// nowhere else to live. The split is by question, not by convenience: project.mjs
|
|
6
|
+
// answers *which rules apply*, this module answers *what files that produces*.
|
|
7
|
+
// Neither imports the other's decision, so neither can quietly change it.
|
|
8
|
+
//
|
|
9
|
+
// The load-bearing rule in here is that this is the SINGLE place a rule file is
|
|
10
|
+
// turned into bytes. `plan` writes what these functions return and `checkTargets`
|
|
11
|
+
// compares against the same construction. The step-6 defect was two places
|
|
12
|
+
// computing the desired region and the gate then reporting drift on a clean tree,
|
|
13
|
+
// which is the failure mode a PR cannot review because both places look right.
|
|
14
|
+
|
|
15
|
+
import { readdir, readFile, readlink, stat } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { diff, parse, inject } from './markers.mjs';
|
|
19
|
+
import { ruleName } from './mdc.mjs';
|
|
20
|
+
import { foldsRules, frontmatterBlock, POINTERS, rulePath, SURFACES } from './surfaces.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A managed rule file is a GENERATED FILE, unlike AGENTS.md.
|
|
24
|
+
*
|
|
25
|
+
* The body after the frontmatter is the region, full stop: there is no hand-owned
|
|
26
|
+
* prose to preserve, because any prose there is exactly the divergent copy this
|
|
27
|
+
* mechanism retires. Appending instead would leave two `## Requirements` sections
|
|
28
|
+
* in one file — the duplication the estate's own review agent is instructed to
|
|
29
|
+
* block, produced by the tool meant to remove it. So the existing frontmatter is
|
|
30
|
+
* kept (the editor reads it before any rule text) and everything below it is
|
|
31
|
+
* replaced.
|
|
32
|
+
*/
|
|
33
|
+
export async function ruleTargets(root, rule, body, sha, resolved, notes) {
|
|
34
|
+
const targets = [];
|
|
35
|
+
const name = ruleName(rule);
|
|
36
|
+
|
|
37
|
+
const folding = foldsRules(resolved);
|
|
38
|
+
|
|
39
|
+
for (const s of resolved.surfaces) {
|
|
40
|
+
if (!s.rule) continue;
|
|
41
|
+
// A reader that already loads AGENTS.md has the rule from the region. Writing the
|
|
42
|
+
// file as well is the duplication S12 exists to remove: measured across the five
|
|
43
|
+
// estate repos that select `opencode,cursor`, 17 rule files carrying 28 KB of text
|
|
44
|
+
// an agent reads twice per session. ADR-021 ruled the opposite on the belief that
|
|
45
|
+
// Cursor had no channel for AGENTS.md; cursor.com/docs/context/rules documents one.
|
|
46
|
+
//
|
|
47
|
+
// Skipped, never deleted. ADR-021 ruling 6: `sync` does not remove a file it once
|
|
48
|
+
// generated, so `compare` reports the leftover with a remedy and a human removes it.
|
|
49
|
+
if (folding && s.readsPrimary === true) {
|
|
50
|
+
const rel = rulePath(s.id, name);
|
|
51
|
+
if (rel) notes.push(`${rel}: not written; ${s.label} reads AGENTS.md, where the rule is already folded`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const rel = rulePath(s.id, name);
|
|
55
|
+
if (!rel) continue;
|
|
56
|
+
const path = join(root, rel);
|
|
57
|
+
|
|
58
|
+
let existing = '';
|
|
59
|
+
let exists = true;
|
|
60
|
+
try {
|
|
61
|
+
existing = await readFile(path, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
exists = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const mine = s.ruleFrontmatter(rule);
|
|
67
|
+
const frontmatter = extractFrontmatter(existing) ?? frontmatterBlock(mine);
|
|
68
|
+
const state = exists ? parse(existing) : { status: 'absent' };
|
|
69
|
+
// A surface that wants no frontmatter must not get a blank line where the
|
|
70
|
+
// frontmatter would have been: Claude's rule files opened with two empty lines
|
|
71
|
+
// before the marker, which is untidy in a file the reader will open, and a
|
|
72
|
+
// difference between surfaces that no check reported.
|
|
73
|
+
const composed =
|
|
74
|
+
state.status === 'ok' ? existing : frontmatter === '' ? '' : `${frontmatter}\n`;
|
|
75
|
+
const result = inject(composed, body, { sha, sources: [rule.source] });
|
|
76
|
+
|
|
77
|
+
// Report the prose being retired rather than letting it vanish into a diff the
|
|
78
|
+
// reader has to notice. Silent deletion is the one thing a reviewer cannot audit.
|
|
79
|
+
const retired = exists && state.status !== 'ok' ? bodyAfterFrontmatter(existing).trim() : '';
|
|
80
|
+
if (retired !== '') {
|
|
81
|
+
notes.push(
|
|
82
|
+
`${rel}: retiring ${Buffer.byteLength(retired)} bytes of divergent hand-written rule text`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
targets.push({
|
|
87
|
+
file: rel,
|
|
88
|
+
surface: s.id,
|
|
89
|
+
path,
|
|
90
|
+
exists,
|
|
91
|
+
action: result.action,
|
|
92
|
+
// As on every other target: the state the file was in, not the action taken on it.
|
|
93
|
+
region: result.region,
|
|
94
|
+
previousBody: result.previousBody,
|
|
95
|
+
reason: result.reason,
|
|
96
|
+
line: result.line,
|
|
97
|
+
bytes: Buffer.byteLength(result.text),
|
|
98
|
+
previousBytes: Buffer.byteLength(existing),
|
|
99
|
+
sha,
|
|
100
|
+
body,
|
|
101
|
+
text: result.text,
|
|
102
|
+
previous: existing,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return { targets };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pointer targets: `CLAUDE.md` and `GEMINI.md` as symlinks to `AGENTS.md`.
|
|
111
|
+
*
|
|
112
|
+
* A pointer target carries `kind: 'pointer'` and no `text`. The writer makes the
|
|
113
|
+
* link; the checker compares the link's target. Nothing else may write these two
|
|
114
|
+
* paths, and an existing regular file at either path is reported as a conflict
|
|
115
|
+
* rather than replaced, because both are files people wrote by hand — one of them
|
|
116
|
+
* is the corpus's only byte-identical duplicate, and overwriting it silently is
|
|
117
|
+
* the one outcome nobody would forgive.
|
|
118
|
+
*/
|
|
119
|
+
export function pointerTargets(root, resolved = null) {
|
|
120
|
+
// Only the pointers whose surface was selected. `resolved` accepts the surface
|
|
121
|
+
// objects `plan` returns or a bare id list; `null` means every surface, which is
|
|
122
|
+
// what an unadopted caller means and what the unit tests pass.
|
|
123
|
+
const ids =
|
|
124
|
+
resolved === null
|
|
125
|
+
? null
|
|
126
|
+
: new Set(resolved.map((s) => (typeof s === 'string' ? s : s.id)));
|
|
127
|
+
return POINTERS.filter((p) => ids === null || ids.has(p.surface)).map((p) => ({
|
|
128
|
+
file: p.path,
|
|
129
|
+
surface: p.id,
|
|
130
|
+
owner: p.surface,
|
|
131
|
+
kind: 'pointer',
|
|
132
|
+
path: join(root, p.path),
|
|
133
|
+
to: p.to,
|
|
134
|
+
readers: p.readers,
|
|
135
|
+
verified: p.verified,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read the current state of one pointer, for both the writer and the gate.
|
|
141
|
+
*
|
|
142
|
+
* Three answers, not two: absent, already pointing where it should, or something
|
|
143
|
+
* else entirely. "Something else" carries the byte count of a regular file so the
|
|
144
|
+
* refusal names what it would have destroyed.
|
|
145
|
+
*/
|
|
146
|
+
export async function pointerState(root, pointer) {
|
|
147
|
+
// `pointer.path` is already root-joined by `pointerTargets`. Re-joining it here
|
|
148
|
+
// would look in `<root><root>/CLAUDE.md` and report every live pointer as absent
|
|
149
|
+
// — which is what happened the first time this ran, and which a unit test that
|
|
150
|
+
// passes the root twice would never catch.
|
|
151
|
+
const path = pointer.path;
|
|
152
|
+
let link = null;
|
|
153
|
+
try {
|
|
154
|
+
link = await readlink(path);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
// EINVAL, not ENOENT, is what readlink returns for a path that exists and is not
|
|
157
|
+
// a symlink. Treating only ENOENT as "not a symlink" made every hand-written
|
|
158
|
+
// CLAUDE.md report `unreadable` instead of `foreign` — which is precisely the
|
|
159
|
+
// file this function exists to protect, and the one the corpus actually
|
|
160
|
+
// contains. Both codes mean "no link here"; anything else is a real fault.
|
|
161
|
+
if (e.code !== 'ENOENT' && e.code !== 'EINVAL') {
|
|
162
|
+
return { file: pointer.file, state: 'unreadable', detail: e.code };
|
|
163
|
+
}
|
|
164
|
+
// Not a symlink. Either nothing is there, or a regular file is — or a DIRECTORY,
|
|
165
|
+
// which is neither. A directory at `CLAUDE.md` is a real thing a repository has: it
|
|
166
|
+
// is where per-project Claude instructions live, and the corpus contains one. Read
|
|
167
|
+
// as "absent" it is invisible: `check` reported nothing at all, and `sync` crashed
|
|
168
|
+
// with `EEXIST` on the `symlink()` call *after* writing every other target, so the
|
|
169
|
+
// run left a half-migrated repo and named `simt-dev doctor` as the remedy, which
|
|
170
|
+
// reports nothing about it either. `stat` is what tells a directory from a file, and
|
|
171
|
+
// it is asked before the read because reading a directory as utf8 throws EISDIR.
|
|
172
|
+
try {
|
|
173
|
+
const st = await stat(path);
|
|
174
|
+
if (st.isDirectory()) {
|
|
175
|
+
let entries = 0;
|
|
176
|
+
try {
|
|
177
|
+
entries = (await readdir(path)).length;
|
|
178
|
+
} catch {
|
|
179
|
+
entries = 0;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
file: pointer.file,
|
|
183
|
+
state: 'foreign',
|
|
184
|
+
bytes: null,
|
|
185
|
+
entries,
|
|
186
|
+
detail: 'a directory, not a pointer',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const text = await readFile(path, 'utf8');
|
|
190
|
+
return {
|
|
191
|
+
file: pointer.file,
|
|
192
|
+
state: 'foreign',
|
|
193
|
+
bytes: Buffer.byteLength(text),
|
|
194
|
+
detail: 'a file, not a pointer',
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
return { file: pointer.file, state: 'absent' };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (link === pointer.to) return { file: pointer.file, state: 'current', link };
|
|
201
|
+
return { file: pointer.file, state: 'mispointed', link, detail: `points at ${link}` };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function surfaceFrontmatter(surfaceId, rule) {
|
|
205
|
+
const s = SURFACES.find((x) => x.id === surfaceId);
|
|
206
|
+
return s && s.ruleFrontmatter ? s.ruleFrontmatter(rule) : [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The per-surface path lives in surfaces.mjs next to the row that justifies it;
|
|
210
|
+
// re-exported so a caller building a target never has to know which module owns
|
|
211
|
+
// path construction as opposed to frontmatter construction.
|
|
212
|
+
export { rulePath };
|
|
213
|
+
|
|
214
|
+
export function extractFrontmatter(text) {
|
|
215
|
+
const m = /^---\n[\s\S]*?\n---\n?/.exec(text);
|
|
216
|
+
return m ? m[0].trimEnd() : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function bodyAfterFrontmatter(text) {
|
|
220
|
+
return text.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
|
221
|
+
}
|