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,349 @@
|
|
|
1
|
+
// The generated-region primitive.
|
|
2
|
+
//
|
|
3
|
+
// This is the whole reason the estate can share rules without a git submodule and
|
|
4
|
+
// without a runtime symlink (docs/plans/0001 section 2.2). A repo keeps its own
|
|
5
|
+
// hand-written AGENTS.md; simt-dev owns only the bytes between two markers.
|
|
6
|
+
//
|
|
7
|
+
// The four properties that make it safe to hand to 16 repos:
|
|
8
|
+
//
|
|
9
|
+
// idempotent injecting the same body twice yields identical bytes, so `sync`
|
|
10
|
+
// never produces a spurious diff and CI never flakes
|
|
11
|
+
// contained bytes outside the markers are never touched, so hand-written
|
|
12
|
+
// content cannot be clobbered by a projection
|
|
13
|
+
// diagnosable the region records which simt-dev SHA produced it, so drift is
|
|
14
|
+
// detectable rather than invisible (ADR: 0001 section 7)
|
|
15
|
+
// honest a malformed region is reported as malformed instead of being
|
|
16
|
+
// silently appended to, which is how a projection engine quietly
|
|
17
|
+
// duplicates a rule
|
|
18
|
+
|
|
19
|
+
// The comment syntax a managed file speaks, and where in it the region goes.
|
|
20
|
+
//
|
|
21
|
+
// A marker has to be invisible to the tool that reads the file the region lives in,
|
|
22
|
+
// so the syntax is a property of the FILE, not of this module. Markdown and every
|
|
23
|
+
// `.mdc` are HTML comments; `.editorconfig` is INI, where `#` is the comment
|
|
24
|
+
// character and an HTML comment would be parsed as a malformed key and make every
|
|
25
|
+
// editor fall back to its own defaults - the file would be worse than absent.
|
|
26
|
+
//
|
|
27
|
+
// `where: 'top'` exists for the same reason. In a file whose semantics are
|
|
28
|
+
// last-definition-wins, a shared block appended below the repo's own settings
|
|
29
|
+
// silently overrules them. No byte is removed, so nothing looks clobbered, and the
|
|
30
|
+
// repo's setting simply stops taking effect. Putting the shared block first means
|
|
31
|
+
// the repo always wins, and `check` reports a region that has been moved down.
|
|
32
|
+
import { CLI, MARKER_PREFIX, MARKER_PREFIX_LEGACY } from '../name.mjs';
|
|
33
|
+
|
|
34
|
+
// The region namespace moved with the command (ADR-056), like the manifest. The read
|
|
35
|
+
// regexes accept both spellings, so a repository still stamped with the legacy prefix
|
|
36
|
+
// is seen rather than reported as unadopted, and the first sync migrates it in place:
|
|
37
|
+
// the composed region carries only the new prefix, so the body comparison in `inject`
|
|
38
|
+
// finds drift and rewrites it. Writing never emits the legacy spelling, so the window
|
|
39
|
+
// closes on every write. A file holding BOTH regions is a state only a hand edit can
|
|
40
|
+
// make; the marker validation below reports it as malformed instead of guessing which
|
|
41
|
+
// region wins, and a human deletes the old one.
|
|
42
|
+
//
|
|
43
|
+
// Both prefixes are pinned to literals in test/naming.test.mjs, so they carry nothing
|
|
44
|
+
// that needs escaping inside the regexes built here.
|
|
45
|
+
const PREFIX = `(?:${MARKER_PREFIX}|${MARKER_PREFIX_LEGACY})`;
|
|
46
|
+
|
|
47
|
+
export const STYLES = {
|
|
48
|
+
html: {
|
|
49
|
+
begin: `<!-- ${MARKER_PREFIX}:begin -->`,
|
|
50
|
+
end: `<!-- ${MARKER_PREFIX}:end -->`,
|
|
51
|
+
line: (s) => `<!-- ${s} -->`,
|
|
52
|
+
note: (first, second) => `<!-- ${first}\n ${second} -->`,
|
|
53
|
+
sha: new RegExp(`<!--\\s*${PREFIX}:sha=([0-9a-f]{7,40})\\s*-->`),
|
|
54
|
+
beginRe: new RegExp(`^<!--\\s*${PREFIX}:begin\\s*-->$`, 'i'),
|
|
55
|
+
endRe: new RegExp(`^<!--\\s*${PREFIX}:end\\s*-->$`, 'i'),
|
|
56
|
+
where: 'bottom',
|
|
57
|
+
},
|
|
58
|
+
hash: {
|
|
59
|
+
begin: `# ${MARKER_PREFIX}:begin`,
|
|
60
|
+
end: `# ${MARKER_PREFIX}:end`,
|
|
61
|
+
line: (s) => `# ${s}`,
|
|
62
|
+
note: (first, second) => `# ${first}\n# ${second}`,
|
|
63
|
+
sha: new RegExp(`^#\\s*${PREFIX}:sha=([0-9a-f]{7,40})\\s*$`, 'm'),
|
|
64
|
+
beginRe: new RegExp(`^#\\s*${PREFIX}:begin\\s*$`, 'i'),
|
|
65
|
+
endRe: new RegExp(`^#\\s*${PREFIX}:end\\s*$`, 'i'),
|
|
66
|
+
where: 'top',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const BEGIN = STYLES.html.begin;
|
|
71
|
+
export const END = STYLES.html.end;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The separator between hand-written text and the region, in the shape a Markdown
|
|
75
|
+
* formatter insists on.
|
|
76
|
+
*
|
|
77
|
+
* Markdown treats an HTML comment that follows a paragraph, a list, a fenced block
|
|
78
|
+
* or a heading as a new block, and every formatter inserts the blank line separating
|
|
79
|
+
* the two before writing it back. Measured with Prettier 3.8.1: a region appended
|
|
80
|
+
* after one newline comes back rewritten, and `--check` exits 1 — on a file the
|
|
81
|
+
* projection got right and the repository's own gate is simply enforcing. The
|
|
82
|
+
* projection emits the formatter's shape from the start, for the same reason it
|
|
83
|
+
* emits the trailing newline `compose` already owns.
|
|
84
|
+
*
|
|
85
|
+
* Note the list case is worse than a blank line: without one, Prettier reads the
|
|
86
|
+
* region as a continuation of the last list item and indents all of it.
|
|
87
|
+
*/
|
|
88
|
+
function seam(before) {
|
|
89
|
+
if (before === '') return '';
|
|
90
|
+
return before.endsWith('\n\n') ? before : `${before.replace(/\n+$/, '')}\n\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function styleOf(style) {
|
|
94
|
+
return typeof style === 'string' ? (STYLES[style] ?? STYLES.html) : (style ?? STYLES.html);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const FENCE = /^\s*(`{3,}|~{3,})/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Split a document into the parts around the generated region.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} text
|
|
103
|
+
* @param {string|object} [style] a key of `STYLES`, or a style row
|
|
104
|
+
* @returns {{status: 'absent'|'ok'|'malformed', before: string, body: string,
|
|
105
|
+
* after: string, reason?: string, sha?: string}}
|
|
106
|
+
*/
|
|
107
|
+
export function parse(text, style) {
|
|
108
|
+
const s = styleOf(style);
|
|
109
|
+
const lines = text.split('\n');
|
|
110
|
+
const absent = { status: 'absent', before: text, body: '', after: '' };
|
|
111
|
+
|
|
112
|
+
const markers = [];
|
|
113
|
+
let fence = null;
|
|
114
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
115
|
+
const line = lines[i];
|
|
116
|
+
// Markers inside a fenced block are documentation, not control. An AGENTS.md
|
|
117
|
+
// that explains this mechanism with an example must not confuse the engine.
|
|
118
|
+
const fenceMatch = FENCE.exec(line);
|
|
119
|
+
if (fenceMatch) {
|
|
120
|
+
const char = fenceMatch[1][0];
|
|
121
|
+
if (fence === null) fence = char;
|
|
122
|
+
else if (fence === char) fence = null;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (fence !== null) continue;
|
|
126
|
+
if (s.beginRe.test(line)) markers.push({ kind: 'begin', i });
|
|
127
|
+
else if (s.endRe.test(line)) markers.push({ kind: 'end', i });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (markers.length === 0) return absent;
|
|
131
|
+
|
|
132
|
+
const first = markers[0];
|
|
133
|
+
if (first.kind !== 'begin') {
|
|
134
|
+
return malformed(lines, 0, first.i, 'end marker before any begin marker');
|
|
135
|
+
}
|
|
136
|
+
const last = markers[markers.length - 1];
|
|
137
|
+
if (last.kind !== 'end') {
|
|
138
|
+
return malformed(lines, first.i, first.i, 'begin marker with no matching end marker');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const between = markers.slice(1, -1);
|
|
142
|
+
const strayBegin = between.find((m) => m.kind === 'begin');
|
|
143
|
+
if (strayBegin) {
|
|
144
|
+
return malformed(lines, first.i, strayBegin.i, 'nested begin marker');
|
|
145
|
+
}
|
|
146
|
+
const strayEnd = between.find((m) => m.kind === 'end');
|
|
147
|
+
if (strayEnd) {
|
|
148
|
+
return malformed(lines, first.i, strayEnd.i, 'nested end marker');
|
|
149
|
+
}
|
|
150
|
+
if (markers.length > 2 && markers.slice(1).some((m) => m.kind === 'end' && m.i < last.i)) {
|
|
151
|
+
return malformed(lines, first.i, last.i, 'more than one region');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const body = lines.slice(first.i + 1, last.i).join('\n');
|
|
155
|
+
const sha = readSha(body, s);
|
|
156
|
+
return {
|
|
157
|
+
status: 'ok',
|
|
158
|
+
before: lines.slice(0, first.i).join('\n'),
|
|
159
|
+
body,
|
|
160
|
+
after: lines.slice(last.i + 1).join('\n'),
|
|
161
|
+
sha,
|
|
162
|
+
// The last byte of the end marker. A reader that stops at a fixed offset never
|
|
163
|
+
// sees a region whose end is past that offset, however correct the body is.
|
|
164
|
+
endByte: Buffer.byteLength(lines.slice(0, last.i + 1).join('\n')),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function malformed(lines, from, at, reason) {
|
|
169
|
+
return {
|
|
170
|
+
status: 'malformed',
|
|
171
|
+
before: lines.join('\n'),
|
|
172
|
+
body: '',
|
|
173
|
+
after: '',
|
|
174
|
+
reason,
|
|
175
|
+
line: at + 1,
|
|
176
|
+
fromLine: from + 1,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Pull the recorded source SHA out of the region's provenance header. */
|
|
181
|
+
function readSha(body, style) {
|
|
182
|
+
const m = style.sha.exec(body);
|
|
183
|
+
return m ? m[1] : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Render the full region text, markers and provenance header included.
|
|
188
|
+
* The header lives *inside* the region so it regenerates with the body and can
|
|
189
|
+
* never drift independently of it.
|
|
190
|
+
*/
|
|
191
|
+
export function compose(body, { sha, sources = [] } = {}, style) {
|
|
192
|
+
const s = styleOf(style);
|
|
193
|
+
// A stamp this tool cannot read back is worse than no stamp: `parse` matches
|
|
194
|
+
// `[0-9a-f]{7,40}`, so the old `unknown` fallback produced a region that reported
|
|
195
|
+
// no provenance at all, while `upgrade` still compared it against real pins as a
|
|
196
|
+
// version (ADR-032). Identity is now a content digest and is always present, so the
|
|
197
|
+
// fallback is gone rather than fixed - but the asymmetry it created is worth naming.
|
|
198
|
+
if (typeof sha !== 'string' || !/^[0-9a-f]{7,40}$/.test(sha)) {
|
|
199
|
+
throw new Error(`compose: sha must match [0-9a-f]{7,40}, got ${JSON.stringify(sha)}`);
|
|
200
|
+
}
|
|
201
|
+
// Write-new, like the markers: the legacy spelling is read but never emitted, so a
|
|
202
|
+
// migrated region is stable and a legacy one drifts exactly once.
|
|
203
|
+
const header = [s.line(`${MARKER_PREFIX}:generated`), s.line(`${MARKER_PREFIX}:sha=${sha}`)];
|
|
204
|
+
if (sources.length > 0) {
|
|
205
|
+
header.push(s.line(`${MARKER_PREFIX}:sources=${sources.join(',')}`));
|
|
206
|
+
}
|
|
207
|
+
// Addressed to whoever reads this repo, not to whoever owns the rules upstream.
|
|
208
|
+
// "Edit the source in simt-dev" was the wrong instruction twice over: a consumer
|
|
209
|
+
// of the published package has no such tree, and an agent reading this repo has
|
|
210
|
+
// no business editing the region it was told not to edit. The only two actions
|
|
211
|
+
// available to a reader are: don't edit it, or regenerate it.
|
|
212
|
+
header.push(
|
|
213
|
+
s.note(
|
|
214
|
+
'Generated content. Do not edit by hand - your changes are overwritten.',
|
|
215
|
+
`Run \`${CLI} sync --yes\` to regenerate, or \`${CLI} check\` to see what is stale.`,
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
const trimmed = body.replace(/\n+$/, '');
|
|
219
|
+
// Two lines here exist only because a real repo proved they are needed. A
|
|
220
|
+
// formatter that runs on commit - several repositories have one, and three of the
|
|
221
|
+
// other repos do too - rewrote the region on the very first commit: it inserted a
|
|
222
|
+
// blank line after the header comment and added the trailing newline. Both edits
|
|
223
|
+
// are things prettier considers correct, and either one alone makes `check`
|
|
224
|
+
// permanently red on a repo whose own tooling is working correctly. A generated
|
|
225
|
+
// file that a formatter rewrites is a fight the projection loses, so the
|
|
226
|
+
// projection emits the formatter's shape in the first place. The blank line before
|
|
227
|
+
// END is the same class: prettier treats an HTML comment after a paragraph as a
|
|
228
|
+
// new block and inserts the separating blank line itself, which made
|
|
229
|
+
// a `format:check` fail on a file whose generated region was correct.
|
|
230
|
+
const out =
|
|
231
|
+
trimmed === ''
|
|
232
|
+
? [s.begin, ...header, s.end]
|
|
233
|
+
: [s.begin, ...header, '', ...trimmed.split('\n'), '', s.end];
|
|
234
|
+
return `${out.join('\n')}\n`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Put the region back into the parts either side of it.
|
|
239
|
+
*
|
|
240
|
+
* `where` decides which side is authoritative. It is a style property rather than
|
|
241
|
+
* a caller argument because the correct answer is a fact about the file's syntax,
|
|
242
|
+
* and a caller who has to remember it is a caller who will get it wrong once.
|
|
243
|
+
*/
|
|
244
|
+
function splice(state, region, where) {
|
|
245
|
+
if (where === 'top') {
|
|
246
|
+
// Top placement is a repair, not a relocation: a region below the file's own
|
|
247
|
+
// settings is moved back above them, which is the one thing the bytes either
|
|
248
|
+
// side of the markers are allowed to move for. So the result here is not
|
|
249
|
+
// byte-comparable with the input even when nothing has changed.
|
|
250
|
+
const rest = `${state.before}${state.before === '' ? '' : '\n'}${state.after}`
|
|
251
|
+
.replace(/^\n+/, '')
|
|
252
|
+
.replace(/\n+$/, '');
|
|
253
|
+
if (rest === '') return region;
|
|
254
|
+
return `${region}\n${rest}\n`;
|
|
255
|
+
}
|
|
256
|
+
const before = seam(state.before);
|
|
257
|
+
const after = state.after.startsWith('\n') ? state.after : state.after === '' ? '' : `\n${state.after}`;
|
|
258
|
+
return `${before}${region}${after}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Insert or replace the region in `text`, preserving everything outside it.
|
|
263
|
+
*
|
|
264
|
+
* `region` and `previousBody` are the state the document was in, not the action taken on
|
|
265
|
+
* it. A caller asking "did this repository's *rules* change?" cannot read that off
|
|
266
|
+
* `action`: a wrong seam with a correct body is `updated` (ADR-037), and so is a changed
|
|
267
|
+
* body. Reporting "the rules changed" for the first is a false cause, and `upgrade` prints
|
|
268
|
+
* it to whoever ran the command. Two of them also answer "has this repo ever been
|
|
269
|
+
* adopted?" — `region: 'absent'` is that answer, and reading it off `previousBytes` gets a
|
|
270
|
+
* repository with hand-written prose and no region wrong.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} [opts] `style` overrides the syntax; `where` overrides placement.
|
|
273
|
+
* @returns {{text: string, action: 'added'|'updated'|'unchanged'|'malformed', reason?: string,
|
|
274
|
+
* region: 'absent'|'ok'|'malformed', previousBody: string}}
|
|
275
|
+
*/
|
|
276
|
+
export function inject(text, body, meta, opts = {}) {
|
|
277
|
+
const s = styleOf(opts.style);
|
|
278
|
+
const where = opts.where ?? s.where;
|
|
279
|
+
const state = parse(text, s);
|
|
280
|
+
const seen = { region: state.status, previousBody: state.body ?? '' };
|
|
281
|
+
if (state.status === 'malformed') {
|
|
282
|
+
return { ...seen, text, action: 'malformed', reason: state.reason, line: state.line };
|
|
283
|
+
}
|
|
284
|
+
const region = compose(body, meta, s);
|
|
285
|
+
|
|
286
|
+
if (state.status === 'absent') {
|
|
287
|
+
if (where === 'top') {
|
|
288
|
+
const rest = text.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
289
|
+
return { ...seen, text: rest === '' ? region : `${region}\n${rest}\n`, action: 'added' };
|
|
290
|
+
}
|
|
291
|
+
const base = seam(text);
|
|
292
|
+
// No trailing newline here, and that is load-bearing. `compose` already
|
|
293
|
+
// terminates the region with one; adding a second left every adopted file
|
|
294
|
+
// ending in a blank line. Nothing in this tool noticed, because `check`
|
|
295
|
+
// compares region bodies and the byte after END is not part of the region —
|
|
296
|
+
// but a repository's own `git diff --check` gate did, and it is a gate
|
|
297
|
+
// that repo runs before every commit. The `updated` branch below has always
|
|
298
|
+
// been correct for exactly this reason.
|
|
299
|
+
//
|
|
300
|
+
// `seam` replaced a hand-rolled equivalent here. The two agreed, which is why the
|
|
301
|
+
// seam defect survived a read of this branch: it looks like the place the blank
|
|
302
|
+
// line is added, and it is — for a document with no region yet. It is not where
|
|
303
|
+
// the defect was, which is in `splice`.
|
|
304
|
+
return {
|
|
305
|
+
...seen,
|
|
306
|
+
text: `${base}${region}`,
|
|
307
|
+
action: 'added',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Compare ignoring only the trailing newline the region may pick up from the
|
|
312
|
+
// document; any other byte difference is real drift worth rewriting.
|
|
313
|
+
const current = state.body.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
314
|
+
const next = region.slice(s.begin.length + 1, region.length - s.end.length - 1);
|
|
315
|
+
const unchanged = current === next.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
316
|
+
const spliced = splice(state, region, where);
|
|
317
|
+
|
|
318
|
+
// Comparing the region BODY is not enough to decide, because the byte between the
|
|
319
|
+
// hand-written text and the region is part of the file a Markdown formatter reads
|
|
320
|
+
// too. A body that matches with a seam that does not is red on `format:check`, and
|
|
321
|
+
// reporting `unchanged` there means `check` finds no drift and `sync` never repairs
|
|
322
|
+
// it - the repository stays red with no remedy the tool offers. Top placement is
|
|
323
|
+
// excluded because there the splice is a repair that legitimately moves bytes
|
|
324
|
+
// outside the markers, so the two files differ for a reason unrelated to the seam.
|
|
325
|
+
const settled = unchanged && (where === 'top' || spliced === text);
|
|
326
|
+
if (settled) return { ...seen, text, action: 'unchanged' };
|
|
327
|
+
|
|
328
|
+
return { ...seen, text: spliced, action: 'updated' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Compare a document against a desired region body without writing.
|
|
333
|
+
* `meta` must be the same provenance the writer would use, so a region produced
|
|
334
|
+
* by an older simt-dev SHA reads as drift rather than as unchanged.
|
|
335
|
+
*
|
|
336
|
+
* @returns {{action: 'added'|'updated'|'unchanged'|'malformed', reason?: string, line?: number,
|
|
337
|
+
* region: 'absent'|'ok'|'malformed', previousBody: string}}
|
|
338
|
+
*/
|
|
339
|
+
export function diff(text, body, meta, opts = {}) {
|
|
340
|
+
const state = parse(text, styleOf(opts.style));
|
|
341
|
+
if (state.status === 'malformed') {
|
|
342
|
+
return { region: 'malformed', previousBody: '', action: 'malformed', reason: state.reason, line: state.line };
|
|
343
|
+
}
|
|
344
|
+
if (state.status === 'absent') {
|
|
345
|
+
return { region: 'absent', previousBody: '', action: 'added' };
|
|
346
|
+
}
|
|
347
|
+
const probe = inject(text, body, meta, opts);
|
|
348
|
+
return { region: probe.region, previousBody: probe.previousBody, action: probe.action };
|
|
349
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Project `.mdc` rules as generated regions.
|
|
2
|
+
//
|
|
3
|
+
// The estate carried three always-on `.mdc` rule families across the Social repos
|
|
4
|
+
// in eight files, and no two were byte-identical. The worst pair differed by one
|
|
5
|
+
// byte while three semantically loaded lines changed, so no size-, hash- or
|
|
6
|
+
// line-count-based duplicate detector could ever have flagged it. This module is
|
|
7
|
+
// the answer to that finding: one canonical body, projected.
|
|
8
|
+
//
|
|
9
|
+
// Two decisions are load-bearing and neither is obvious:
|
|
10
|
+
//
|
|
11
|
+
// 1. VARIANT IS THE WRONG SHAPE FOR THIS CONTENT. The two divergent BLOCK
|
|
12
|
+
// bullets in `test-gate` are complementary (one catches a consumer that
|
|
13
|
+
// requires an unshipped field, the other a producer that ships an untested
|
|
14
|
+
// one), and the two divergent Requirements bullets in
|
|
15
|
+
// `preview-postflight` are complementary too (a build-config instruction and
|
|
16
|
+
// a credential-handling prohibition). Choosing one per repo would keep the
|
|
17
|
+
// estate's worst bug — a rule file that quietly stops catching half of what
|
|
18
|
+
// it exists to catch. So the canonical body carries BOTH, unconditionally.
|
|
19
|
+
//
|
|
20
|
+
// 2. WHAT ACTUALLY VARIES IS SUBSTRATE, NOT POLICY. A plan-tracker glob, a test
|
|
21
|
+
// path, a production domain. Those become declared substitutions with
|
|
22
|
+
// defaults, so an unconfigured repo still receives a correct, complete rule
|
|
23
|
+
// and the defaults are visible rather than guessed at render time.
|
|
24
|
+
|
|
25
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
26
|
+
|
|
27
|
+
import { frontmatterBlock } from './surfaces.mjs';
|
|
28
|
+
import { dirname, join, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
import { parse } from './markers.mjs';
|
|
32
|
+
|
|
33
|
+
// ROOT is computed here rather than imported: project.mjs is the caller of every
|
|
34
|
+
// function below, so importing it back would create a cycle for the sake of one
|
|
35
|
+
// constant.
|
|
36
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
37
|
+
const MDC_DIR = join(ROOT, 'rules', 'mdc');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The keys a canonical `.mdc` may declare: ours prefixed, the editor's as documented
|
|
41
|
+
* (ADR-022).
|
|
42
|
+
*
|
|
43
|
+
* The allowlist earns its place twice. It is what tells a reader of the file which
|
|
44
|
+
* reader owns a key, and it is what turns an unprefixed simt key into a reported
|
|
45
|
+
* defect — which is the failure ADR-016 shipped once, a rule that reached no repo
|
|
46
|
+
* because `applies` was absent, silently and with a test green.
|
|
47
|
+
*/
|
|
48
|
+
const KNOWN_KEYS = new Set([
|
|
49
|
+
'simt-id',
|
|
50
|
+
'simt-target',
|
|
51
|
+
'simt-title',
|
|
52
|
+
'simt-applies',
|
|
53
|
+
'simt-opt-in',
|
|
54
|
+
'simt-substitutions',
|
|
55
|
+
'description',
|
|
56
|
+
'alwaysApply',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** A bare key beside a prefixed one is the typo this rule exists to catch. */
|
|
60
|
+
function unknownKey(key) {
|
|
61
|
+
const prefixed = `simt-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
62
|
+
return KNOWN_KEYS.has(prefixed)
|
|
63
|
+
? `key "${key}" is this tool's own, not the editor's; it is spelled "${prefixed}"`
|
|
64
|
+
: `unrecognised frontmatter key "${key}"; simt-dev's own keys are simt-id, simt-target, simt-title, simt-applies, simt-opt-in, simt-substitutions`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read every canonical `.mdc` rule with its frontmatter parsed.
|
|
69
|
+
*
|
|
70
|
+
* A rule without `simt-id` or `simt-target` is a defect rather than a default:
|
|
71
|
+
* `simt-id` is what a manifest keys its substitutions on, and `simt-target` is the
|
|
72
|
+
* only thing saying where the projected file goes. A rule that silently wrote nowhere
|
|
73
|
+
* would look like a successful sync.
|
|
74
|
+
*/
|
|
75
|
+
export async function loadMdcRules() {
|
|
76
|
+
let names;
|
|
77
|
+
try {
|
|
78
|
+
names = (await readdir(MDC_DIR)).filter((n) => n.endsWith('.mdc')).sort();
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const file of names) {
|
|
85
|
+
const text = await readFile(join(MDC_DIR, file), 'utf8');
|
|
86
|
+
const meta = parseFrontmatter(text);
|
|
87
|
+
// `simt-substitutions:` is a YAML list of maps, which the shared line-based
|
|
88
|
+
// frontmatter parser cannot express, so it is parsed separately and strictly.
|
|
89
|
+
const subs = parseSubstitutions(text);
|
|
90
|
+
const unknown = Object.keys(meta).filter((k) => !KNOWN_KEYS.has(k));
|
|
91
|
+
out.push({
|
|
92
|
+
file,
|
|
93
|
+
source: `rules/mdc/${file}`,
|
|
94
|
+
id: meta['simt-id'] ?? null,
|
|
95
|
+
target: meta['simt-target'] ?? null,
|
|
96
|
+
title: meta['simt-title'] ?? null,
|
|
97
|
+
description: meta.description ?? null,
|
|
98
|
+
alwaysApply: meta.alwaysApply === true,
|
|
99
|
+
// `simt-applies` is 'always' or a tag array; `simt-opt-in` means the repo must name
|
|
100
|
+
// this rule in its manifest rather than matching on a detected signal. A rule that
|
|
101
|
+
// names one product's Workers and secrets cannot be selected by stack, because
|
|
102
|
+
// detection cannot distinguish a Workers repo that has that problem from one
|
|
103
|
+
// that does not.
|
|
104
|
+
applies:
|
|
105
|
+
meta['simt-applies'] === 'always'
|
|
106
|
+
? 'always'
|
|
107
|
+
: Array.isArray(meta['simt-applies'])
|
|
108
|
+
? meta['simt-applies']
|
|
109
|
+
: [],
|
|
110
|
+
optIn: meta['simt-opt-in'] === true,
|
|
111
|
+
// A rule with no `simt-substitutions:` block has no placeholders, so an empty
|
|
112
|
+
// list is the correct answer. (This was `substitutions` — an undefined binding
|
|
113
|
+
// that would only have fired on a future rule without the block, which is
|
|
114
|
+
// exactly the kind of latent crash a new rule is most likely to trigger.)
|
|
115
|
+
substitutions: subs,
|
|
116
|
+
bytes: Buffer.byteLength(text),
|
|
117
|
+
body: stripFrontmatter(text),
|
|
118
|
+
error:
|
|
119
|
+
meta['simt-id'] === null
|
|
120
|
+
? 'missing simt-id in frontmatter'
|
|
121
|
+
: meta['simt-target'] === null
|
|
122
|
+
? 'missing simt-target in frontmatter'
|
|
123
|
+
: unknown.length > 0
|
|
124
|
+
? unknownKey(unknown[0])
|
|
125
|
+
: null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `simt-substitutions:` is a list of `- key: x` blocks. Any deviation is reported
|
|
132
|
+
* rather than skipped, because a silently-dropped substitution projects a rule
|
|
133
|
+
* containing a literal `{{placeholder}}`. */
|
|
134
|
+
function parseSubstitutions(text) {
|
|
135
|
+
const m = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
136
|
+
if (!m) return [];
|
|
137
|
+
const block = m[1];
|
|
138
|
+
if (!/^simt-substitutions:/m.test(block)) return [];
|
|
139
|
+
|
|
140
|
+
const out = [];
|
|
141
|
+
const re = /^\s*-\s*key:\s*(\S+)\s*$/gm;
|
|
142
|
+
const hits = [...block.matchAll(re)];
|
|
143
|
+
for (const hit of hits) {
|
|
144
|
+
const start = hit.index + hit[0].length;
|
|
145
|
+
const tail = block.slice(start);
|
|
146
|
+
const next = /^\s*-\s*key:/m.exec(tail.slice(1));
|
|
147
|
+
const scope = next ? tail.slice(0, next.index + 1) : tail;
|
|
148
|
+
const def = /^\s*default:\s*(.*)$/m.exec(scope);
|
|
149
|
+
const desc = /^\s*describe:\s*(.*)$/m.exec(scope);
|
|
150
|
+
out.push({
|
|
151
|
+
key: hit[1],
|
|
152
|
+
default: def ? unquote(def[1].trim()) : '',
|
|
153
|
+
describe: desc ? unquote(desc[1].trim()) : '',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseFrontmatter(text) {
|
|
160
|
+
const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
|
|
161
|
+
if (!m) return {};
|
|
162
|
+
const out = {};
|
|
163
|
+
for (const line of m[1].split('\n')) {
|
|
164
|
+
// `simt-substitutions:` opens a nested block this parser cannot represent, so
|
|
165
|
+
// it and its list items are skipped here and read by parseSubstitutions.
|
|
166
|
+
if (/^\s*-\s/.test(line)) continue;
|
|
167
|
+
const kv = /^([a-zA-Z-]+):\s*(.*)$/.exec(line);
|
|
168
|
+
if (!kv) continue;
|
|
169
|
+
const [, key, raw] = kv;
|
|
170
|
+
if (raw === '') continue;
|
|
171
|
+
if (raw === 'always') out[key] = 'always';
|
|
172
|
+
else if (raw === 'true') out[key] = true;
|
|
173
|
+
else if (raw === 'false') out[key] = false;
|
|
174
|
+
else if (raw.startsWith('[')) {
|
|
175
|
+
out[key] = raw
|
|
176
|
+
.slice(1, raw.lastIndexOf(']'))
|
|
177
|
+
.split(',')
|
|
178
|
+
.map((s) => s.trim())
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
} else out[key] = unquote(raw.trim());
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function unquote(s) {
|
|
186
|
+
if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
|
|
187
|
+
return s.slice(1, -1);
|
|
188
|
+
}
|
|
189
|
+
return s;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function stripFrontmatter(text) {
|
|
193
|
+
return text
|
|
194
|
+
.replace(/^---\n[\s\S]*?\n---\n*/, '')
|
|
195
|
+
.replace(/^#\s+.*\n+/, '')
|
|
196
|
+
.trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Substitute declared placeholders.
|
|
201
|
+
*
|
|
202
|
+
* An empty-string default is a deliberate "this repo has nothing to add here"
|
|
203
|
+
* and the line it sits on is DROPPED. That is how `preview-postflight` reaches
|
|
204
|
+
* a repository without printing an empty bullet, and how a repo with no
|
|
205
|
+
* production-domain note gets a clean document instead of a blank one.
|
|
206
|
+
*
|
|
207
|
+
* A placeholder with neither a declared nor a default value is a defect: the
|
|
208
|
+
* rendered rule would ship a literal `{{key}}`, which reads as a finished rule
|
|
209
|
+
* and is not one.
|
|
210
|
+
*/
|
|
211
|
+
export function renderMdc(rule, declared = {}) {
|
|
212
|
+
const problems = [];
|
|
213
|
+
let body = rule.body;
|
|
214
|
+
|
|
215
|
+
for (const sub of rule.substitutions) {
|
|
216
|
+
const has = Object.prototype.hasOwnProperty.call(declared, sub.key);
|
|
217
|
+
const provided = has ? declared[sub.key] : undefined;
|
|
218
|
+
|
|
219
|
+
if (has && provided === '') {
|
|
220
|
+
// A repo declared this key and declared it empty: the line it sits on is
|
|
221
|
+
// removed. This is how preview-postflight reaches a repository without
|
|
222
|
+
// printing a blank bullet.
|
|
223
|
+
body = dropOrReplace(body, sub.key, '');
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (provided === undefined || provided === null || provided === '') {
|
|
228
|
+
// Fall back to the default. An empty DEFAULT is also a drop instruction,
|
|
229
|
+
// which is how the same file serves a repo that has nothing to add.
|
|
230
|
+
if (sub.default === '') {
|
|
231
|
+
body = dropOrReplace(body, sub.key, '');
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (sub.default === undefined) {
|
|
235
|
+
problems.push(`substitution ${sub.key} has no default and none declared`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
body = dropOrReplace(body, sub.key, sub.default);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
body = dropOrReplace(body, sub.key, String(provided));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A key that was already reported as unsubstitutable is not reported a second
|
|
246
|
+
// time as a leftover placeholder. Two messages for one fault trains a reader
|
|
247
|
+
// to skim past problems, and the first message is the actionable one.
|
|
248
|
+
const reported = new Set(
|
|
249
|
+
problems
|
|
250
|
+
.filter((p) => /^substitution /.test(p))
|
|
251
|
+
.map((p) => /^substitution ([^ ]+) /.exec(p)?.[1]),
|
|
252
|
+
);
|
|
253
|
+
const leftover = [...body.matchAll(/\{\{([a-zA-Z0-9_-]+)\}\}/g)].map((m) => m[1]);
|
|
254
|
+
for (const key of new Set(leftover)) {
|
|
255
|
+
if (reported.has(key)) continue;
|
|
256
|
+
problems.push(`placeholder {{${key}}} was never substituted`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { body, problems };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Replace every `{{key}}`, or remove the whole bullet when the value is empty.
|
|
264
|
+
*
|
|
265
|
+
* "Whole bullet" rather than "the line containing the token": an empty value on a
|
|
266
|
+
* wrapped bullet leaves its continuation lines behind, and the rule then reads as
|
|
267
|
+
* half a sentence starting with a lowercase word. A markdown bullet is the token's
|
|
268
|
+
* line plus every more-indented line that follows it.
|
|
269
|
+
*
|
|
270
|
+
* One placeholder per line is a rule on the content side, not a preference. Dropping
|
|
271
|
+
* a line removes EVERY token on it, so `{{a}}{{b}}` with `a` empty deletes `b` too —
|
|
272
|
+
* silently, and with no leftover placeholder left to report. Two independent optional
|
|
273
|
+
* clauses on one line lose one clause with no diagnostic at all.
|
|
274
|
+
*/
|
|
275
|
+
function dropOrReplace(text, key, value) {
|
|
276
|
+
const token = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
|
277
|
+
if (value !== '') return text.replace(token, value);
|
|
278
|
+
// A second, non-global pattern for the line scan: reusing a `/g` regex with
|
|
279
|
+
// `.test()` carries `lastIndex` between lines, so every other line would be
|
|
280
|
+
// tested against the wrong offset.
|
|
281
|
+
const probe = new RegExp(`\\{\\{${key}\\}\\}`);
|
|
282
|
+
|
|
283
|
+
const kept = [];
|
|
284
|
+
let dropping = false;
|
|
285
|
+
for (const line of text.split('\n')) {
|
|
286
|
+
if (probe.test(line)) {
|
|
287
|
+
dropping = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (dropping) {
|
|
291
|
+
// A continuation of the bullet just dropped: indented, and not itself the
|
|
292
|
+
// start of a new block element.
|
|
293
|
+
const indent = /^(\s*)/.exec(line)[1].length;
|
|
294
|
+
if (line.trim() === '') continue;
|
|
295
|
+
if (indent > 0 && !/^\s*([-*+]|\d+\.)\s/.test(line)) continue;
|
|
296
|
+
dropping = false;
|
|
297
|
+
}
|
|
298
|
+
kept.push(line);
|
|
299
|
+
}
|
|
300
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The frontmatter a projected `.mdc` keeps. `alwaysApply` and `description` are
|
|
304
|
+
* read by the editor before any rule text, so they belong outside the region. */
|
|
305
|
+
export function mdcFrontmatter(rule) {
|
|
306
|
+
return frontmatterBlock([
|
|
307
|
+
`description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
|
|
308
|
+
`alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
|
|
309
|
+
]);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The rule's bare name, taken from the path it declares.
|
|
315
|
+
*
|
|
316
|
+
* `simt-target` is the canonical declaration and predates the surface table; deriving
|
|
317
|
+
* every other surface's path from its basename means a rule names itself once and
|
|
318
|
+
* a rename moves every editor's file with it, instead of four paths that can
|
|
319
|
+
* disagree about which rule a file holds.
|
|
320
|
+
*/
|
|
321
|
+
export function ruleName(rule) {
|
|
322
|
+
if (typeof rule.target !== 'string' || rule.target === '') return null;
|
|
323
|
+
const base = rule.target.slice(rule.target.lastIndexOf('/') + 1);
|
|
324
|
+
return base.replace(/\.mdc$/, '');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export { MDC_DIR, parse as parseRegion };
|