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,640 @@
|
|
|
1
|
+
// Which rules apply to a repo.
|
|
2
|
+
//
|
|
3
|
+
// Split from project.mjs, which crossed the 400-line review trigger (ADR-005)
|
|
4
|
+
// twice while gaining two unrelated concerns. The rule is that a module answers
|
|
5
|
+
// one question: this one answers *which rules*, project.mjs answers *what files
|
|
6
|
+
// that produces*, targets.mjs answers *how a rule is rendered per surface*.
|
|
7
|
+
//
|
|
8
|
+
// Selection is a pure function of the repo's signals plus the manifest, with no
|
|
9
|
+
// filesystem beyond reading the rule files themselves. That purity is what makes
|
|
10
|
+
// the profile-selection test possible at all, and it is why `select` takes a
|
|
11
|
+
// pre-read rule list rather than a path.
|
|
12
|
+
//
|
|
13
|
+
// The core rule set is always included. Stack profiles are selected by
|
|
14
|
+
// intersection with the repo's fingerprint - platform tags *and* emitted facts,
|
|
15
|
+
// which are interchangeable in a profile (ADR-013) - plus one fallback profile so
|
|
16
|
+
// a JS/TS repo is never left with core rules and nothing else.
|
|
17
|
+
//
|
|
18
|
+
// Profiles may live in the repo, not only here. `.simt-dev/profiles/*.md` uses the
|
|
19
|
+
// same frontmatter contract as `rules/stacks/*.md` and is selected by the same
|
|
20
|
+
// matcher, so supporting an ecosystem nobody here has heard of requires no code
|
|
21
|
+
// change and no release.
|
|
22
|
+
|
|
23
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { dirname, join, resolve } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
|
|
29
|
+
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
30
|
+
import { foldsRules, frontmatterBlock, resolveSurfaces } from './surfaces.mjs';
|
|
31
|
+
import { ruleTargets } from './targets.mjs';
|
|
32
|
+
import { manifestPath } from './manifest.mjs';
|
|
33
|
+
import { BASELINES } from './baselines.mjs';
|
|
34
|
+
|
|
35
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
export const ROOT = resolve(HERE, '..', '..');
|
|
37
|
+
const RULES_DIR = join(ROOT, 'rules');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The identity recorded in generated regions and in the manifest pin.
|
|
41
|
+
*
|
|
42
|
+
* This is a digest of the CONTENT that leaves this package, not a commit id, and the
|
|
43
|
+
* distinction is not cosmetic. `git rev-parse` run inside `node_modules/` walks *up*
|
|
44
|
+
* until it finds a `.git`, so for anyone who installed this package into a repository -
|
|
45
|
+
* the whole audience for it - the stamp recorded the CONSUMER's HEAD. Git succeeded, so
|
|
46
|
+
* no fallback could catch it. A consumer's HEAD moves on every commit they make,
|
|
47
|
+
* including commits that touch nothing this tool projects, so `check` reported DRIFT on
|
|
48
|
+
* all three generated files after an unrelated commit and exited 1, and `upgrade` named
|
|
49
|
+
* a cause ("the rules changed") that was false.
|
|
50
|
+
*
|
|
51
|
+
* Where git could not answer at all - a published tarball has no `.git` (`files` in
|
|
52
|
+
* package.json does not include one and npm never packs it) - the old code returned the
|
|
53
|
+
* string `unknown`, and that was treated as a value: since the marker grammar reads
|
|
54
|
+
* `[0-9a-f]{7,40}` the tool could not read back its own stamp, and `upgrade` compared
|
|
55
|
+
* `unknown` against a real pin as though they were two versions, reporting "the rules
|
|
56
|
+
* changed" when they had not and overwriting a good pin with `unknown` on `--yes`,
|
|
57
|
+
* unrecoverably. That is the failure this replaces (ADR-032).
|
|
58
|
+
*
|
|
59
|
+
* The digest covers every byte string that leaves the package, and nothing else. That
|
|
60
|
+
* set is `rules/` and `graph/` on disk plus the RENDERED baseline bodies: the shared
|
|
61
|
+
* `.editorconfig` and `.gitattributes` blocks are strings a repository receives, so by
|
|
62
|
+
* ADR-015's rule a source is identified by its content rather than by the file it
|
|
63
|
+
* happens to live in, and a body defined in `src/` is a source like any other.
|
|
64
|
+
*
|
|
65
|
+
* It deliberately excludes `src/` as a directory. A refactor that changes no rule, no
|
|
66
|
+
* registry entry and no baseline body produces byte-identical output, and stamping those
|
|
67
|
+
* consumers would report drift where there is none - which is the estate's own rule that
|
|
68
|
+
* a report must be a fact.
|
|
69
|
+
*
|
|
70
|
+
* Sorted path-then-bytes, so the value depends on content and not on readdir order,
|
|
71
|
+
* and length-prefixed so no rename can be crafted to collide with a rename that did
|
|
72
|
+
* not happen (the reason to hash a structured encoding rather than joined text).
|
|
73
|
+
*/
|
|
74
|
+
export async function provenanceDigest() {
|
|
75
|
+
const hash = createHash('sha256');
|
|
76
|
+
for (const { path, bytes } of await provenanceInputs()) {
|
|
77
|
+
hash.update(`${path}:${bytes.length}:`);
|
|
78
|
+
hash.update(bytes);
|
|
79
|
+
}
|
|
80
|
+
return hash.digest('hex').slice(0, 40);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The exact byte strings `provenanceDigest` hashes, as `{path, bytes}` in hash order.
|
|
85
|
+
*
|
|
86
|
+
* Exported because "the digest covers the right things" is a claim about a SET, and a
|
|
87
|
+
* set that is only ever assembled inside the function that hashes it cannot be
|
|
88
|
+
* asserted from outside: the test would have to mutate the package to see the answer.
|
|
89
|
+
* This is the list, so a test can ask what is in it, and the rendering is one function
|
|
90
|
+
* both the digest and that test read - the arrangement ADR-042's ruling 3 settled on.
|
|
91
|
+
*
|
|
92
|
+
* @returns {Promise<{path: string, bytes: Buffer}[]>}
|
|
93
|
+
*/
|
|
94
|
+
export async function provenanceInputs() {
|
|
95
|
+
const inputs = [];
|
|
96
|
+
for (const dir of ['rules', 'graph']) {
|
|
97
|
+
for (const rel of await walk(join(ROOT, dir))) {
|
|
98
|
+
inputs.push({ path: `${dir}/${rel}`, bytes: await readFile(join(ROOT, dir, rel)) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Last, and keyed by the FILE it becomes in the adopting repository rather than the
|
|
102
|
+
// module it is written in: the stamp answers what content produced those bytes, and
|
|
103
|
+
// `.editorconfig` is the name a reader of a generated region recognises.
|
|
104
|
+
for (const b of BASELINES) {
|
|
105
|
+
inputs.push({ path: `baselines/${b.file}`, bytes: Buffer.from(b.body()) });
|
|
106
|
+
}
|
|
107
|
+
return inputs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @returns {Promise<string[]>} repo-relative paths, sorted, directories omitted. */
|
|
111
|
+
async function walk(dir, prefix = '') {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
114
|
+
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
115
|
+
if (entry.isDirectory()) out.push(...(await walk(join(dir, entry.name), rel)));
|
|
116
|
+
else if (entry.isFile()) out.push(rel);
|
|
117
|
+
}
|
|
118
|
+
return out.sort();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Repo-local profile directory, relative to a repo root (ADR-013 section 2). */
|
|
122
|
+
export const LOCAL_PROFILE_DIR = '.simt-dev/profiles';
|
|
123
|
+
|
|
124
|
+
/** Every key a stack profile or core rule may declare is this tool's own, so every
|
|
125
|
+
* one carries the `simt-` prefix (ADR-022). A profile carrying a bare key would read
|
|
126
|
+
* as an editor's instruction — Cursor, Copilot and Antigravity all parse a
|
|
127
|
+
* frontmatter block the same way — and would be silently inert. */
|
|
128
|
+
const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback']);
|
|
129
|
+
|
|
130
|
+
/** Report the first thing wrong with a profile's frontmatter, in the order a reader
|
|
131
|
+
* fixes it: no id to select it by, then a key that will not be read, then a tag the
|
|
132
|
+
* detector cannot emit.
|
|
133
|
+
*
|
|
134
|
+
* The third check is the one a repo can get wrong by itself. A profile naming a tag
|
|
135
|
+
* no repository emits selects on nothing and fails silently: it loads, it appears in
|
|
136
|
+
* the region's source list, and it is never applied. `graph lint` checks the
|
|
137
|
+
* registry's skills against the same derived vocabulary (ADR-013, Consequences), and
|
|
138
|
+
* nothing checked the profiles until S28 changed six `simt-applies` lists with no
|
|
139
|
+
* guard that any of them named a tag the engine could produce. */
|
|
140
|
+
function describeDefect(meta) {
|
|
141
|
+
if (meta['simt-id'] === undefined) return 'missing simt-id in frontmatter';
|
|
142
|
+
const unknown = Object.keys(meta).find((k) => !PROFILE_KEYS.has(k));
|
|
143
|
+
if (unknown !== undefined) {
|
|
144
|
+
const prefixed = `simt-${unknown.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
145
|
+
return PROFILE_KEYS.has(prefixed)
|
|
146
|
+
? `key "${unknown}" is this tool's own, not the editor's; it is spelled "${prefixed}"`
|
|
147
|
+
: `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback`;
|
|
148
|
+
}
|
|
149
|
+
const applies = meta['simt-applies'];
|
|
150
|
+
if (!Array.isArray(applies)) return null;
|
|
151
|
+
const undeclared = applies.find((tag) => !KNOWN_TAGS.has(tag));
|
|
152
|
+
return undeclared === undefined
|
|
153
|
+
? null
|
|
154
|
+
: `simt-applies names "${undeclared}", which fingerprint() never emits, so this profile could never be selected`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Manifest key holding per-rule substitution values, keyed by rule id.
|
|
159
|
+
*
|
|
160
|
+
* Substitutions live in the manifest rather than in the rule file because they are
|
|
161
|
+
* facts about the repo — which glob holds its plans, which domains are production
|
|
162
|
+
* — while a rule is shared by every repo that adopts it. Putting them in the rule
|
|
163
|
+
* either hard-codes one repo's paths for everyone or pushes the decision back
|
|
164
|
+
* into prose, which is the drift this mechanism exists to remove.
|
|
165
|
+
*/
|
|
166
|
+
export const SUBSTITUTIONS_KEY = 'substitutions';
|
|
167
|
+
|
|
168
|
+
export function isEligible(rule, signals, optedIn) {
|
|
169
|
+
if (rule.optIn === true) return optedIn.has(rule.id);
|
|
170
|
+
if (rule.applies === 'always') return true;
|
|
171
|
+
return Array.isArray(rule.applies) && rule.applies.some((t) => signals.has(t));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Read the repo's declared substitutions and opt-ins once.
|
|
176
|
+
*
|
|
177
|
+
* Both callers used to read and parse `simt-dev.json` separately — and `checkTargets`
|
|
178
|
+
* did it inside its per-rule loop, so a repo with three projected `.mdc` rules read the
|
|
179
|
+
* manifest three times to reach the same two values. A helper that answers once is also
|
|
180
|
+
* the only way to be sure the writer and the gate cannot disagree about what was
|
|
181
|
+
* declared.
|
|
182
|
+
*/
|
|
183
|
+
export async function readDeclared(root) {
|
|
184
|
+
let parsed = {};
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(await readFile(await manifestPath(root), 'utf8')) ?? {};
|
|
187
|
+
} catch {
|
|
188
|
+
parsed = {};
|
|
189
|
+
}
|
|
190
|
+
const declarations = parsed[SUBSTITUTIONS_KEY] ?? {};
|
|
191
|
+
const list = declarations.__optIn;
|
|
192
|
+
return {
|
|
193
|
+
values: declarations,
|
|
194
|
+
optIn: new Set(Array.isArray(list) ? list : []),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Render every canonical `.mdc` rule into its own generated region.
|
|
202
|
+
*
|
|
203
|
+
* A rule with a defect (no id, no target) is reported and skipped rather than
|
|
204
|
+
* written somewhere guessed: a rule file that silently stopped being projected
|
|
205
|
+
* would look exactly like a rule file that had nothing to say.
|
|
206
|
+
*/
|
|
207
|
+
export async function mdcTargets(root, sha, manifest, flags) {
|
|
208
|
+
const targets = [];
|
|
209
|
+
|
|
210
|
+
const { resolved, bodies, problems, notes, skipped } = await ruleBodies(root, manifest, flags);
|
|
211
|
+
|
|
212
|
+
// A surface that folds its rules into the primary document contributes no file;
|
|
213
|
+
// `ruleTargets` skips it. Folding itself is `desiredRegion`'s job, and it reads the
|
|
214
|
+
// same `bodies` from the same call, so the region and the files cannot disagree.
|
|
215
|
+
if (foldsRules(resolved) && !resolved.surfaces.some((s) => s.rule)) {
|
|
216
|
+
if (bodies.length > 0) {
|
|
217
|
+
notes.push(
|
|
218
|
+
`every rule is folded into ${resolved.surfaces[0].primary}; add a surface with its own rule path for a file per rule`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return { targets, problems, notes };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const { rule, body } of bodies) {
|
|
225
|
+
const built = await ruleTargets(root, rule, body, sha, resolved, notes);
|
|
226
|
+
targets.push(...built.targets);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (skipped.length > 0) {
|
|
230
|
+
notes.push(`no .mdc rule projected for ${skipped.join(', ')}; opt in with substitutions.__optIn`);
|
|
231
|
+
}
|
|
232
|
+
return { targets, problems, notes };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Select the per-rule canonical rules and render each one, once.
|
|
237
|
+
*
|
|
238
|
+
* Extracted from `mdcTargets` because ADR-021 needs the SAME bodies for two
|
|
239
|
+
* different destinations: folded into the primary region when the selected surface
|
|
240
|
+
* has no per-rule path, and written as one file per surface when it does. Two
|
|
241
|
+
* selection passes would be two answers to "which rules apply", which is the defect
|
|
242
|
+
* that produced eight divergent `.mdc` files in the first place.
|
|
243
|
+
*/
|
|
244
|
+
/**
|
|
245
|
+
* The value every selected surface gives `key`, or null when they do not all give one.
|
|
246
|
+
*
|
|
247
|
+
* A missing entry is a disagreement: the surface that has no name for the tool would
|
|
248
|
+
* otherwise be handed another harness's.
|
|
249
|
+
*/
|
|
250
|
+
function agreedSubstitution(surfaces, key) {
|
|
251
|
+
if (surfaces.length === 0) return null;
|
|
252
|
+
const values = surfaces.map((s) => s.substitutions?.[key]);
|
|
253
|
+
if (values.some((v) => typeof v !== 'string' || v === '')) return null;
|
|
254
|
+
if (new Set(values).size !== 1) return null;
|
|
255
|
+
return values[0];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The values renderMdc receives for one rule: what the repo declared, plus a
|
|
260
|
+
* surface-named value used only when every selected surface names the same one.
|
|
261
|
+
*
|
|
262
|
+
* The body is written once and shared by every selected surface, so one harness's
|
|
263
|
+
* tool name must not be what another harness reads. `ruleBodies` (the writer) and
|
|
264
|
+
* `checkTargets` (the gate) both go through this, so they cannot disagree about what
|
|
265
|
+
* the body should contain — that divergence made `check` report drift on a tree
|
|
266
|
+
* `init` had just written, once a surface named a substitution (S45's askTool).
|
|
267
|
+
*/
|
|
268
|
+
export function ruleValues(rule, declared, surfaces) {
|
|
269
|
+
const values = { ...declared };
|
|
270
|
+
for (const sub of rule.substitutions) {
|
|
271
|
+
if (Object.prototype.hasOwnProperty.call(values, sub.key)) continue;
|
|
272
|
+
const agreed = agreedSubstitution(surfaces, sub.key);
|
|
273
|
+
if (agreed !== null) values[sub.key] = agreed;
|
|
274
|
+
}
|
|
275
|
+
return values;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function ruleBodies(root, manifest, flags) {
|
|
279
|
+
const problems = [];
|
|
280
|
+
const notes = [];
|
|
281
|
+
const skipped = [];
|
|
282
|
+
const bodies = [];
|
|
283
|
+
|
|
284
|
+
const { stack, facts } = await fingerprint(root);
|
|
285
|
+
const signals = new Set([...stack, ...facts]);
|
|
286
|
+
|
|
287
|
+
const rules = await loadMdcRules();
|
|
288
|
+
if (rules.length === 0) {
|
|
289
|
+
notes.push('no canonical .mdc rules shipped in this build');
|
|
290
|
+
const resolved = resolveSurfaces(manifest, flags ?? {});
|
|
291
|
+
return { resolved, bodies, problems, notes, skipped };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const declared = await readDeclared(root);
|
|
295
|
+
|
|
296
|
+
const resolved = resolveSurfaces(manifest, flags ?? {});
|
|
297
|
+
for (const p of resolved.problems) problems.push(p);
|
|
298
|
+
|
|
299
|
+
// A manifest key that no shipped rule declares is a typo, or a leftover from a rule
|
|
300
|
+
// that was renamed. Both read as "configured" in the one file a reviewer opens to
|
|
301
|
+
// see what a repo has chosen, and neither has any effect — so one is reported rather
|
|
302
|
+
// than ignored. `__optIn` is the one key with no rule of the same name.
|
|
303
|
+
const knownKeys = new Set(['__optIn']);
|
|
304
|
+
for (const rule of rules) for (const sub of rule.substitutions) knownKeys.add(sub.key);
|
|
305
|
+
for (const ruleId of Object.keys(declared.values)) {
|
|
306
|
+
if (ruleId === '__optIn') continue;
|
|
307
|
+
if (!rules.some((r) => r.id === ruleId)) {
|
|
308
|
+
notes.push(
|
|
309
|
+
`manifest declares substitutions for "${ruleId}", which no canonical rule matches; ignored`,
|
|
310
|
+
);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
for (const key of Object.keys(declared.values[ruleId] ?? {})) {
|
|
314
|
+
if (!knownKeys.has(key)) {
|
|
315
|
+
problems.push(`${ruleId}: manifest declares substitution "${key}", which the rule does not define`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const rule of rules) {
|
|
321
|
+
if (rule.error !== null) {
|
|
322
|
+
problems.push(`${rule.file}: ${rule.error}`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// Selection mirrors the stack profiles: `simt-applies: always` is unconditional,
|
|
326
|
+
// a `simt-applies` array intersects the repo's signals, and an opt-in rule waits for
|
|
327
|
+
// the manifest to name it. A repo that matches nothing gets no `.mdc` rule
|
|
328
|
+
// rather than a plausible-looking one it cannot act on.
|
|
329
|
+
if (!isEligible(rule, signals, declared.optIn)) {
|
|
330
|
+
skipped.push(rule.id);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const { body, problems: renderProblems } = renderMdc(
|
|
334
|
+
rule,
|
|
335
|
+
ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
|
|
336
|
+
);
|
|
337
|
+
for (const p of renderProblems) problems.push(`${rule.file}: ${p}`);
|
|
338
|
+
if (renderProblems.length > 0) continue;
|
|
339
|
+
|
|
340
|
+
// One canonical body, one target per selected surface. The body is identical
|
|
341
|
+
// everywhere; only the frontmatter the editor reads before any rule text
|
|
342
|
+
// differs. Without this loop a repo in a Claude Code session receives
|
|
343
|
+
// AGENTS.md and none of the always-on process rules, because those lived only
|
|
344
|
+
// in a directory only Cursor reads.
|
|
345
|
+
bodies.push({ rule, body });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { resolved, bodies, problems, notes, skipped };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The one place the desired region is computed. `plan` (which writes) and
|
|
353
|
+
* `checkTarget` (which gates) both go through this, so they cannot disagree
|
|
354
|
+
* about what the region should contain — a divergence here would report drift
|
|
355
|
+
* where none exists, and CI would flake on a clean tree.
|
|
356
|
+
*/
|
|
357
|
+
export async function desiredRegion(root, flags) {
|
|
358
|
+
const { stack, facts, manifest } = await fingerprint(root);
|
|
359
|
+
const all = await loadRules(root);
|
|
360
|
+
const { selected, fallback, notes } = select(all, stack, facts);
|
|
361
|
+
|
|
362
|
+
let body = render(selected);
|
|
363
|
+
let sources = selected.map((r) => r.source);
|
|
364
|
+
const folded = [];
|
|
365
|
+
|
|
366
|
+
// When every selected surface carries its rules inside the primary document, the
|
|
367
|
+
// per-rule bodies belong in the region too — otherwise a repo on the standard
|
|
368
|
+
// surface receives AGENTS.md and none of the always-on process rules, because
|
|
369
|
+
// those had nowhere else to live. One file, one region, one place a reader opens.
|
|
370
|
+
const { resolved, bodies, problems, notes: ruleNotes } = await ruleBodies(
|
|
371
|
+
root,
|
|
372
|
+
manifest,
|
|
373
|
+
flags ?? {},
|
|
374
|
+
);
|
|
375
|
+
notes.push(...ruleNotes);
|
|
376
|
+
if (foldsRules(resolved)) {
|
|
377
|
+
for (const { rule, body: ruleBody } of bodies) {
|
|
378
|
+
// `## Process:`, not `### id`. The body already uses `##`, and a third-level
|
|
379
|
+
// heading in front of a second-level one is the break a reader cannot repair.
|
|
380
|
+
body += `\n\n${frameSource(rule, ruleBody, 'process')}`;
|
|
381
|
+
sources.push(rule.source);
|
|
382
|
+
folded.push(rule.id);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const meta = {
|
|
387
|
+
sha: await provenanceDigest(),
|
|
388
|
+
sources,
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
stack,
|
|
392
|
+
facts,
|
|
393
|
+
manifest,
|
|
394
|
+
all,
|
|
395
|
+
profiles: selected.map((r) => r.id),
|
|
396
|
+
folded,
|
|
397
|
+
surfaces: resolved.ids,
|
|
398
|
+
surfaceSource: resolved.source,
|
|
399
|
+
problems,
|
|
400
|
+
fallback,
|
|
401
|
+
notes,
|
|
402
|
+
body,
|
|
403
|
+
meta,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Read every rule file once, with its frontmatter parsed, plus any repo-local
|
|
409
|
+
* profile under `.simt-dev/profiles/`.
|
|
410
|
+
*
|
|
411
|
+
* A rule without a `simt-id` is a defect, not a default: ids are what `rules why`
|
|
412
|
+
* and the generated region's source list refer to.
|
|
413
|
+
*
|
|
414
|
+
* A local profile with the same id as a central one SHADOWS it — replaces it
|
|
415
|
+
* rather than stacking with it. Two profiles claiming the same id would project
|
|
416
|
+
* the same rules twice, and duplicated source-of-truth text is a review blocker,
|
|
417
|
+
* not a style nit. The shadow is reported in `notes` so it is never silent.
|
|
418
|
+
*/
|
|
419
|
+
export async function loadRules(root = null) {
|
|
420
|
+
const entries = await readdir(RULES_DIR, { withFileTypes: true });
|
|
421
|
+
const files = entries.filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name);
|
|
422
|
+
const stackFiles = entries.some((e) => e.isDirectory() && e.name === 'stacks')
|
|
423
|
+
? (await readdir(join(RULES_DIR, 'stacks')))
|
|
424
|
+
.filter((n) => n.endsWith('.md'))
|
|
425
|
+
.map((n) => `stacks/${n}`)
|
|
426
|
+
: [];
|
|
427
|
+
|
|
428
|
+
const collected = [];
|
|
429
|
+
|
|
430
|
+
for (const file of [...files, ...stackFiles].sort()) {
|
|
431
|
+
collected.push({ file, dir: RULES_DIR, origin: 'central', source: `rules/${file}` });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (root !== null) {
|
|
435
|
+
const localDir = join(root, LOCAL_PROFILE_DIR);
|
|
436
|
+
let localNames = [];
|
|
437
|
+
try {
|
|
438
|
+
localNames = (await readdir(localDir)).filter((n) => n.endsWith('.md')).sort();
|
|
439
|
+
} catch {
|
|
440
|
+
localNames = [];
|
|
441
|
+
}
|
|
442
|
+
for (const name of localNames) {
|
|
443
|
+
collected.push({
|
|
444
|
+
file: name,
|
|
445
|
+
dir: localDir,
|
|
446
|
+
origin: 'local',
|
|
447
|
+
source: `${LOCAL_PROFILE_DIR}/${name}`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const out = [];
|
|
453
|
+
for (const item of collected) {
|
|
454
|
+
const text = await readFile(join(item.dir, item.file), 'utf8');
|
|
455
|
+
const meta = parseFrontmatter(text);
|
|
456
|
+
out.push({
|
|
457
|
+
file: item.file,
|
|
458
|
+
origin: item.origin,
|
|
459
|
+
source: item.source,
|
|
460
|
+
id: meta['simt-id'] ?? null,
|
|
461
|
+
title: meta['simt-title'] ?? null,
|
|
462
|
+
applies: meta['simt-applies'] ?? [],
|
|
463
|
+
fallback: meta['simt-fallback'] === true,
|
|
464
|
+
bytes: Buffer.byteLength(text),
|
|
465
|
+
body: stripFrontmatter(text),
|
|
466
|
+
error: describeDefect(meta),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Selection is pure set intersection: core always, then every profile whose
|
|
474
|
+
* `simt-applies` intersects the repo's signals. `node` is the fallback, so it is only
|
|
475
|
+
* chosen when nothing more specific matched — otherwise every JS repo would
|
|
476
|
+
* receive both node and react-web.
|
|
477
|
+
*
|
|
478
|
+
* `signals` is stack tags ∪ facts (ADR-013). A profile may name either, and a
|
|
479
|
+
* fact is the more durable choice because it survives the framework being
|
|
480
|
+
* replaced.
|
|
481
|
+
*
|
|
482
|
+
* `shadowed` lists local profiles that replaced a central one, so a caller can
|
|
483
|
+
* report it rather than silently changing what a repo receives.
|
|
484
|
+
*/
|
|
485
|
+
export function select(rules, stack, facts = []) {
|
|
486
|
+
const notes = [];
|
|
487
|
+
const shadowed = [];
|
|
488
|
+
const ids = new Map();
|
|
489
|
+
for (const rule of rules) {
|
|
490
|
+
if (rule.id === null) continue;
|
|
491
|
+
const prior = ids.get(rule.id);
|
|
492
|
+
if (prior === undefined) {
|
|
493
|
+
ids.set(rule.id, rule);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
// A local profile is allowed to replace a central one; two of the same
|
|
497
|
+
// origin are a defect the caller must be able to see.
|
|
498
|
+
const localWins = rule.origin === 'local' && prior.origin !== 'local';
|
|
499
|
+
if (localWins) {
|
|
500
|
+
ids.set(rule.id, rule);
|
|
501
|
+
shadowed.push({
|
|
502
|
+
id: rule.id,
|
|
503
|
+
winner: rule.source,
|
|
504
|
+
replaced: prior.source,
|
|
505
|
+
ok: true,
|
|
506
|
+
});
|
|
507
|
+
} else {
|
|
508
|
+
shadowed.push({
|
|
509
|
+
id: rule.id,
|
|
510
|
+
winner: prior.source,
|
|
511
|
+
replaced: rule.source,
|
|
512
|
+
ok: false,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const keep = new Set(ids.values());
|
|
517
|
+
|
|
518
|
+
const effective = rules.filter((r) => r.id === null || keep.has(r));
|
|
519
|
+
const effCore = effective.filter((r) => r.applies === 'always');
|
|
520
|
+
const effProfiles = effective.filter((r) => Array.isArray(r.applies) && !r.fallback);
|
|
521
|
+
const effFallbacks = effective.filter((r) => r.fallback);
|
|
522
|
+
|
|
523
|
+
const signals = [...new Set([...(stack ?? []), ...(facts ?? [])])];
|
|
524
|
+
const tags = new Set(signals);
|
|
525
|
+
const matched = effProfiles.filter((r) => r.applies.some((tag) => tags.has(tag)));
|
|
526
|
+
|
|
527
|
+
let usedFallback = false;
|
|
528
|
+
const selected = [...effCore, ...matched];
|
|
529
|
+
if (matched.length === 0) {
|
|
530
|
+
for (const f of effFallbacks) {
|
|
531
|
+
if (f.applies.some((tag) => tags.has(tag))) {
|
|
532
|
+
selected.push(f);
|
|
533
|
+
usedFallback = true;
|
|
534
|
+
notes.push(
|
|
535
|
+
`no specific profile matched ${signals.join(', ') || '(no signals)'}; using fallback ${f.id}`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (matched.length === 0 && usedFallback === false) {
|
|
541
|
+
notes.push(
|
|
542
|
+
`no profile matched ${signals.length > 0 ? signals.join(', ') : '(no detected signals)'}; projecting shared rules only`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
for (const s of shadowed.filter((x) => x.ok)) {
|
|
546
|
+
notes.push(`local profile ${s.id} shadows ${s.replaced}`);
|
|
547
|
+
}
|
|
548
|
+
for (const s of shadowed.filter((x) => !x.ok)) {
|
|
549
|
+
notes.push(`duplicate profile id ${s.id}: ignored ${s.replaced}, kept ${s.winner}`);
|
|
550
|
+
}
|
|
551
|
+
return { selected, fallback: usedFallback, notes, shadowed };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The region body. Stack profiles come after core so the general rule wins a
|
|
555
|
+
* conflict: a stack profile refines the shared rule, it does not replace it.
|
|
556
|
+
*
|
|
557
|
+
* Each source is one `##` section. The source's own headings drop one level,
|
|
558
|
+
* so a profile's `##` cannot sit beside core's and look like the same document
|
|
559
|
+
* continuing (S34). */
|
|
560
|
+
export function render(selected) {
|
|
561
|
+
// Strip again here: loadRules already does it, but render is the boundary the
|
|
562
|
+
// region bytes pass through, and a stray frontmatter or duplicated H1 in the
|
|
563
|
+
// output would be projected verbatim.
|
|
564
|
+
return selected
|
|
565
|
+
.map((r) => frameSource(r, stripFrontmatter(r.body).trim(), 'profile'))
|
|
566
|
+
.filter(Boolean)
|
|
567
|
+
.join('\n\n');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* One source, one `##` heading, then its body with every heading demoted.
|
|
572
|
+
*
|
|
573
|
+
* `Applies to` is already the first line of a stack profile once its H1 is gone,
|
|
574
|
+
* so it lands directly under `## Stack: …` instead of under whatever section the
|
|
575
|
+
* previous source ended on.
|
|
576
|
+
*/
|
|
577
|
+
export function frameSource(rule, body, kind) {
|
|
578
|
+
const text = demoteHeadings(body).trim();
|
|
579
|
+
if (!text) return '';
|
|
580
|
+
const title = sourceTitle(rule, kind);
|
|
581
|
+
if (!title) return text;
|
|
582
|
+
return `## ${title}\n\n${text}`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function sourceTitle(rule, kind) {
|
|
586
|
+
if (!rule?.id) return null;
|
|
587
|
+
if (kind === 'process') return `Process: ${rule.id}`;
|
|
588
|
+
if (rule.id === 'core') return 'Core';
|
|
589
|
+
return `Stack: ${rule.id}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** `##` becomes `###`. A fence is left alone: a heading in an example is an example. */
|
|
593
|
+
function demoteHeadings(text) {
|
|
594
|
+
let fence = false;
|
|
595
|
+
return text
|
|
596
|
+
.split('\n')
|
|
597
|
+
.map((line) => {
|
|
598
|
+
if (/^(```|~~~)/.test(line)) {
|
|
599
|
+
fence = !fence;
|
|
600
|
+
return line;
|
|
601
|
+
}
|
|
602
|
+
if (fence) return line;
|
|
603
|
+
return line.replace(/^(#{1,5})(?=\s)/, '$1#');
|
|
604
|
+
})
|
|
605
|
+
.join('\n');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function parseFrontmatter(text) {
|
|
609
|
+
const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
|
|
610
|
+
if (!m) return {};
|
|
611
|
+
const out = {};
|
|
612
|
+
for (const line of m[1].split('\n')) {
|
|
613
|
+
const kv = /^([a-zA-Z-]+):\s*(.*)$/.exec(line);
|
|
614
|
+
if (!kv) continue;
|
|
615
|
+
const [, key, raw] = kv;
|
|
616
|
+
if (raw === 'always') out[key] = 'always';
|
|
617
|
+
else if (raw === 'true') out[key] = true;
|
|
618
|
+
else if (raw === 'false') out[key] = false;
|
|
619
|
+
else if (raw.startsWith('[')) {
|
|
620
|
+
out[key] = raw
|
|
621
|
+
.slice(1, raw.lastIndexOf(']'))
|
|
622
|
+
.split(',')
|
|
623
|
+
.map((s) => s.trim())
|
|
624
|
+
.filter(Boolean);
|
|
625
|
+
} else out[key] = raw.trim();
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Frontmatter and the duplicated H1 are dropped: the region is embedded in a
|
|
631
|
+
* host document that already has a title, so repeating it wastes context. */
|
|
632
|
+
function stripFrontmatter(text) {
|
|
633
|
+
// The frontmatter strip must also swallow the blank lines that follow it, or
|
|
634
|
+
// the H1 strip's `^` anchor lands on a newline and silently matches nothing.
|
|
635
|
+
return text
|
|
636
|
+
.replace(/^---\n[\s\S]*?\n---\n*/, '')
|
|
637
|
+
.replace(/^#\s+.*\n+/, '')
|
|
638
|
+
.trim();
|
|
639
|
+
}
|
|
640
|
+
|