simt-dev 0.7.0 → 0.8.0
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.
- package/CHANGELOG.md +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/docs.mjs +88 -0
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +94 -20
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/sys/proc.mjs +151 -0
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
package/src/projection/lock.mjs
CHANGED
|
@@ -17,6 +17,11 @@ import { LOCK_FILE, LOCK_LEGACY } from '../name.mjs';
|
|
|
17
17
|
import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
|
|
18
18
|
import { readManifest } from './manifest.mjs';
|
|
19
19
|
|
|
20
|
+
/** The lock's own format. Bumped only when a reader must interpret an old file
|
|
21
|
+
* differently; an absent field means 1, because every lock written before this
|
|
22
|
+
* field existed was written by the only format there has been. */
|
|
23
|
+
const LOCK_VERSION = 1;
|
|
24
|
+
|
|
20
25
|
/** Manifest keys that change what the region contains. Anything else in the file is
|
|
21
26
|
* the repository's own notes, and hashing it would make a comment fail the lock. */
|
|
22
27
|
const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
|
|
@@ -39,7 +44,7 @@ export async function computeLock(root, flags = {}, manifest = undefined) {
|
|
|
39
44
|
const outputs = createHash('sha256').update(desired.body).digest('hex');
|
|
40
45
|
const hasVariants =
|
|
41
46
|
variants && typeof variants === 'object' && Object.keys(variants).length > 0;
|
|
42
|
-
return hasVariants ? { inputs, outputs, variants } : { inputs, outputs };
|
|
47
|
+
return hasVariants ? { version: LOCK_VERSION, inputs, outputs, variants } : { version: LOCK_VERSION, inputs, outputs };
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
|
@@ -71,6 +76,11 @@ export async function readLock(root) {
|
|
|
71
76
|
if (text === null) return { lock: null, error: null };
|
|
72
77
|
try {
|
|
73
78
|
const lock = JSON.parse(text);
|
|
79
|
+
// Absent means 1; a version this build does not know is a lock it cannot judge,
|
|
80
|
+
// and reading it anyway would report drift for a format change.
|
|
81
|
+
if (lock.version !== undefined && (!Number.isInteger(lock.version) || lock.version < 1)) {
|
|
82
|
+
return { lock: null, error: 'version must be an integer of at least 1' };
|
|
83
|
+
}
|
|
74
84
|
if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
|
|
75
85
|
return { lock: null, error: 'must be an object with inputs and outputs' };
|
|
76
86
|
}
|
|
@@ -27,37 +27,27 @@ import { getGate } from '../hooks/gates.mjs';
|
|
|
27
27
|
// Q31: the one Node runner table. This module and the card, capability,
|
|
28
28
|
// dispatcher and CI workflow all spell `<manager> run <script>` from it.
|
|
29
29
|
import { runScript } from '../ecosystem/node-runner.mjs';
|
|
30
|
+
// Q70: the ecosystem records. A new ecosystem is a new file under `ecosystems/`, not a
|
|
31
|
+
// row added to this table. records.mjs imports nothing from this package, so this edge
|
|
32
|
+
// cannot cycle.
|
|
33
|
+
import { ecosystemGateRows } from '../ecosystem/records.mjs';
|
|
30
34
|
|
|
31
35
|
// Imported as well as re-exported: a bare `export { X as Y } from` binds nothing here,
|
|
32
36
|
// and the manifest name is used a dozen times below in remedy strings that name this
|
|
33
37
|
// file by name.
|
|
34
38
|
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
39
|
+
import { GENERATED_KEYS_OF, KEYS, loadManifest, manifestPath } from '../manifest/schema.mjs';
|
|
35
40
|
import { CLI } from '../name.mjs';
|
|
36
41
|
import { packageVersion } from '../package-version.mjs';
|
|
42
|
+
import { defaultGates, planGates } from '../gates/index.mjs';
|
|
43
|
+
export { ECOSYSTEMS, defaultGates, gateRunnable, planGates, unrunnableGates } from '../gates/index.mjs';
|
|
37
44
|
export const MANIFEST = MANIFEST_FILE;
|
|
38
45
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
* The manifest a repository has, which is not necessarily the one this tool would write.
|
|
43
|
-
*
|
|
44
|
-
* The file was renamed from `simt-dev.json` to `simt.json`. Every read site used to say
|
|
45
|
-
* `join(root, MANIFEST)` and would have reported an adopted repository as unadopted the
|
|
46
|
-
* day the constant moved - and "no manifest" means "never adopted", which is the answer
|
|
47
|
-
* that offers to re-adopt a repo that is already done. Fourteen repositories carry the
|
|
48
|
-
* old name on disk.
|
|
49
|
-
*
|
|
50
|
-
* So the old name is read as a fallback and the new one is written. `writeManifest`
|
|
51
|
-
* renames rather than creating a second file, which is how the window closes: the first
|
|
52
|
-
* `simt init` or `simt sync` in an adopted repo retires the old name by moving it.
|
|
53
|
-
*/
|
|
54
|
-
export async function manifestPath(root) {
|
|
55
|
-
const next = join(root, MANIFEST);
|
|
56
|
-
if (existsSync(next)) return next;
|
|
57
|
-
const previous = join(root, MANIFEST_LEGACY);
|
|
58
|
-
return existsSync(previous) ? previous : next;
|
|
59
|
-
}
|
|
46
|
+
// Still exported from here because this is where the manifest lived: every caller that
|
|
47
|
+
// resolved its path through this module keeps working, and the implementation moved.
|
|
48
|
+
export { manifestPath };
|
|
60
49
|
|
|
50
|
+
export { MANIFEST_LEGACY };
|
|
61
51
|
|
|
62
52
|
/**
|
|
63
53
|
* The one definition of the file's own comment, exported because `upgrade` rewrites
|
|
@@ -75,7 +65,7 @@ export const MANIFEST_COMMENT = {
|
|
|
75
65
|
`Written and maintained by simt-dev. \`${CLI} sync --yes\` regenerates the projected rules.`,
|
|
76
66
|
`\`${CLI} init\` refreshes the SHA and keeps every key it does not write, so a value you add here survives a re-init.`,
|
|
77
67
|
`\`plans\` is such a key: it selects the tracker template and the directories \`${CLI} plan\` reads.`,
|
|
78
|
-
'Omit it and the tracker template applies, which reads docs/plans and docs/reviews.',
|
|
68
|
+
'Omit it and the tracker template applies, which reads docs/plans, docs/research and docs/reviews.',
|
|
79
69
|
].join(' '),
|
|
80
70
|
};
|
|
81
71
|
|
|
@@ -83,362 +73,14 @@ export const MANIFEST_COMMENT = {
|
|
|
83
73
|
* Gate names the shared dispatcher knows how to run (step 8). Choosing these from
|
|
84
74
|
* the detected stack rather than asking a human keeps `init` non-interactive.
|
|
85
75
|
*/
|
|
86
|
-
const GATES_BY_STACK = [
|
|
87
|
-
{
|
|
88
|
-
when: ['gradle', 'kotlin', 'connectiq'],
|
|
89
|
-
preCommit: ['format', 'lint', 'i18n-completeness'],
|
|
90
|
-
prePush: ['test'],
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
when: ['python'],
|
|
94
|
-
preCommit: ['format', 'lint', 'i18n-completeness'],
|
|
95
|
-
prePush: ['test', 'coverage'],
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
when: ['node', 'node-web', 'cloudflare-workers', 'typescript'],
|
|
99
|
-
preCommit: ['format', 'lint'],
|
|
100
|
-
prePush: ['test', 'typecheck'],
|
|
101
|
-
},
|
|
102
|
-
];
|
|
103
|
-
|
|
104
|
-
/** Extra gate implied by a specific tag, applied after the table above. */
|
|
105
|
-
const TAG_GATES = [
|
|
106
|
-
{ tag: 'connectiq', preCommit: ['memory', 'codegen-drift'] },
|
|
107
|
-
{ tag: 'hono', preCommit: ['i18n-completeness'] },
|
|
108
|
-
];
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* The ecosystems that produce a fact rather than a stack tag, and what a fresh
|
|
112
|
-
* adoption of one should declare.
|
|
113
|
-
*
|
|
114
|
-
* A row proposes gates the way `GATES_BY_STACK` does, and the fact it keys on is
|
|
115
|
-
* the same signal a profile's `simt-applies` names, so one marker file reaches
|
|
116
|
-
* both: the region this repo reads and the gates its commits run.
|
|
117
|
-
*
|
|
118
|
-
* `commands` is what a FRESH manifest declares for each proposed gate, and it is
|
|
119
|
-
* written only when the probe in `readCapability` found `tool` where this row says
|
|
120
|
-
* it lives. A declared gate that cannot run blocks every commit, so a command is
|
|
121
|
-
* never declared on the faith of a marker file: the marker says the ecosystem is
|
|
122
|
-
* here, the probe says its program can run (ADR-017). A command the repo already
|
|
123
|
-
* declared wins over the seed, because a seed is a default and a declaration is a
|
|
124
|
-
* choice.
|
|
125
|
-
*
|
|
126
|
-
* `file` marks a tool that is a file in the repository rather than a binary on
|
|
127
|
-
* PATH: the Maven wrapper, probed the way this tool already probes `gradlew`.
|
|
128
|
-
*/
|
|
129
|
-
export const ECOSYSTEMS = [
|
|
130
|
-
{
|
|
131
|
-
fact: 'has-cargo-toml',
|
|
132
|
-
tool: 'cargo',
|
|
133
|
-
preCommit: ['format', 'lint'],
|
|
134
|
-
prePush: ['test'],
|
|
135
|
-
commands: { format: 'cargo fmt --check', lint: 'cargo clippy -- -D warnings', test: 'cargo test' },
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
fact: 'has-go-mod',
|
|
139
|
-
tool: 'go',
|
|
140
|
-
preCommit: ['format', 'lint'],
|
|
141
|
-
prePush: ['test'],
|
|
142
|
-
commands: { format: 'test -z "$(gofmt -l .)"', lint: 'go vet ./...', test: 'go test ./...' },
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
fact: 'has-nuget',
|
|
146
|
-
tool: 'dotnet',
|
|
147
|
-
preCommit: ['format'],
|
|
148
|
-
prePush: ['test', 'typecheck'],
|
|
149
|
-
commands: { format: 'dotnet format --verify-no-changes', test: 'dotnet test', typecheck: 'dotnet build -warnaserror' },
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
fact: 'has-swift-package',
|
|
153
|
-
tool: 'swift',
|
|
154
|
-
preCommit: ['format'],
|
|
155
|
-
prePush: ['test', 'typecheck'],
|
|
156
|
-
commands: { format: 'swift format lint -r .', test: 'swift test', typecheck: 'swift build' },
|
|
157
|
-
},
|
|
158
|
-
{
|
|
159
|
-
fact: 'has-deno-config',
|
|
160
|
-
tool: 'deno',
|
|
161
|
-
preCommit: ['format', 'lint'],
|
|
162
|
-
prePush: ['test', 'typecheck'],
|
|
163
|
-
commands: { format: 'deno fmt --check', lint: 'deno lint', test: 'deno test', typecheck: 'deno check .' },
|
|
164
|
-
},
|
|
165
|
-
{
|
|
166
|
-
fact: 'has-gemfile',
|
|
167
|
-
tool: 'bundle',
|
|
168
|
-
preCommit: ['format'],
|
|
169
|
-
prePush: ['test'],
|
|
170
|
-
commands: { format: 'bundle exec rubocop', test: 'bundle exec rspec' },
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
fact: 'has-pubspec',
|
|
174
|
-
tool: 'dart',
|
|
175
|
-
preCommit: ['format', 'lint'],
|
|
176
|
-
prePush: ['test'],
|
|
177
|
-
commands: { format: 'dart format --output=none --set-exit-if-changed .', lint: 'dart analyze', test: 'dart test' },
|
|
178
|
-
},
|
|
179
|
-
{
|
|
180
|
-
fact: 'has-mix',
|
|
181
|
-
tool: 'mix',
|
|
182
|
-
preCommit: ['format'],
|
|
183
|
-
prePush: ['test'],
|
|
184
|
-
commands: { format: 'mix format --check-formatted', test: 'mix test' },
|
|
185
|
-
},
|
|
186
|
-
// The wrapper is a file the repository commits, not a program on PATH. A repo with
|
|
187
|
-
// a pom.xml and no wrapper cannot run `./mvnw -q verify`, so the probe is the file.
|
|
188
|
-
{
|
|
189
|
-
fact: 'has-maven-pom',
|
|
190
|
-
tool: 'mvnw',
|
|
191
|
-
file: true,
|
|
192
|
-
prePush: ['test'],
|
|
193
|
-
commands: { test: './mvnw -q verify' },
|
|
194
|
-
},
|
|
195
|
-
];
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Can this repo actually run the gate?
|
|
199
|
-
*
|
|
200
|
-
* A declared gate that cannot run is worse than an absent one. Absent, an author sees
|
|
201
|
-
* nothing and knows nothing is being checked; declared-and-unrunnable, the dispatcher
|
|
202
|
-
* fails closed on every commit and the only exit an author can find is `--no-verify`,
|
|
203
|
-
* which teaches the whole team that the gate is theatre. The dispatcher is right to
|
|
204
|
-
* fail closed — the defect is declaring a gate this repo has no way to run.
|
|
205
|
-
*
|
|
206
|
-
* The check mirrors what the dispatcher actually attempts:
|
|
207
|
-
* - `script` gates run `<manager> run <script>`, so the script must exist
|
|
208
|
-
* - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
|
|
209
|
-
* installed, not merely declared as a dependency
|
|
210
|
-
* - `command` gates run whatever the manifest's `gates.commands` declares, so a
|
|
211
|
-
* command gate with no declared command is unrunnable by definition
|
|
212
|
-
*
|
|
213
|
-
* @param {object} gate an entry from GATES in src/hooks/gates.mjs
|
|
214
|
-
* @param {{scripts?: Record<string,string>, binaries?: string[], commands?: Record<string,string>}} capability
|
|
215
|
-
*/
|
|
216
|
-
export function gateRunnable(gate, capability = {}) {
|
|
217
|
-
if (!gate) return { runnable: false, why: 'unknown gate' };
|
|
218
|
-
// Only a KNOWN non-npm runner changes the wording. A capability that does not report
|
|
219
|
-
// its runner (a hand-built one, a caller from an older version) is treated as npm,
|
|
220
|
-
// because claiming a repo has no package.json is a much worse guess than assuming it.
|
|
221
|
-
const foreign = isForeignRunner(capability.runner);
|
|
222
|
-
|
|
223
|
-
// A declared command satisfies the gate whatever its kind, because that is the
|
|
224
|
-
// dispatcher's own precedence and the two must never disagree about one manifest.
|
|
225
|
-
const declared = capability.commands?.[gate.name];
|
|
226
|
-
if (declared) {
|
|
227
|
-
return declared.trim() === ''
|
|
228
|
-
? { runnable: false, why: `gates.commands["${gate.name}"] is empty` }
|
|
229
|
-
: { runnable: true, why: `runs: ${declared}` };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (gate.kind === 'builtin') {
|
|
233
|
-
// The one kind with no external requirement: the implementation ships inside
|
|
234
|
-
// the binary that is reading this manifest (ADR-100). Saying otherwise would
|
|
235
|
-
// make `check` report a gate as unrunnable that runs on every commit.
|
|
236
|
-
return { runnable: true, why: 'runs inside the tool itself; nothing to install' };
|
|
237
|
-
}
|
|
238
|
-
if (gate.kind === 'command') {
|
|
239
|
-
return { runnable: false, why: `no command is declared for it in gates.commands` };
|
|
240
|
-
}
|
|
241
|
-
if (gate.kind === 'script') {
|
|
242
|
-
const script = gate.script ?? gate.name;
|
|
243
|
-
// Q31: the runnable sentence names the repo's own runner, never a bare npm
|
|
244
|
-
// assumption — the card, the dispatcher and this check read one resolver.
|
|
245
|
-
const spell = runScript(capability.runner ?? 'npm', script);
|
|
246
|
-
if (capability.scripts?.[script]) return { runnable: true, why: `runs: ${spell}` };
|
|
247
|
-
// Saying "no script in package.json" about a repo with no package.json states a
|
|
248
|
-
// fact about a file that does not exist and implies a fix that cannot be taken.
|
|
249
|
-
return {
|
|
250
|
-
runnable: false,
|
|
251
|
-
why: foreign
|
|
252
|
-
? `this repo runs ${runnerName(capability.runner)}, not npm, so "${spell}" cannot run here`
|
|
253
|
-
: `no "${script}" script in package.json`,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
// staged: a formatter must actually be installed
|
|
257
|
-
for (const tool of gate.tools ?? []) {
|
|
258
|
-
if (!(capability.binaries ?? []).includes(tool)) {
|
|
259
|
-
return {
|
|
260
|
-
runnable: false,
|
|
261
|
-
why: foreign
|
|
262
|
-
? `${tool} is not installed, and this repo has no node_modules/.bin to install it into`
|
|
263
|
-
: `${tool} is not installed in node_modules/.bin`,
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return { runnable: true, why: `formats staged files with ${(gate.tools ?? []).join(', ')}` };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* What the reader has to do to make a dropped gate runnable.
|
|
272
|
-
*
|
|
273
|
-
* A drop is a decision the tool made on the repo's behalf, so it owes the reader the
|
|
274
|
-
* undo. Every remedy is one of three real choices — install the tool, add the script,
|
|
275
|
-
* or name the command — because "this repo cannot run format" with no next step reads
|
|
276
|
-
* as simt-dev deciding formatting does not matter here.
|
|
277
|
-
*
|
|
278
|
-
* The two npm-shaped remedies only apply where npm is the runner. In a Gradle, Python or
|
|
279
|
-
* Rust repo the honest undo is the `command` gate: the repo names its own program, which
|
|
280
|
-
* is the same escape hatch ConnectIQ memory budgets already use.
|
|
281
|
-
*/
|
|
282
|
-
function gateRemedy(gate, { existing = false, runner = null } = {}) {
|
|
283
|
-
const name = gate?.name ?? 'the gate';
|
|
284
|
-
const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
|
|
285
|
-
const foreign = isForeignRunner(runner);
|
|
286
|
-
// Q31: a missing tool in a Node repo names the repo's own installer —
|
|
287
|
-
// `pnpm add -D`, not `npm i -D` — and anything elsewhere keeps the honest
|
|
288
|
-
// undo: name the program under `gates.commands`.
|
|
289
|
-
const addDev = { npm: 'npm i -D', pnpm: 'pnpm add -D', yarn: 'yarn add -D', bun: 'bun add -d' }[runner] ?? null;
|
|
290
|
-
const fix =
|
|
291
|
-
npmShaped && foreign
|
|
292
|
-
? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
|
|
293
|
-
: gate?.kind === 'staged'
|
|
294
|
-
? addDev
|
|
295
|
-
? `install ${gate.tools.join(', ')} as a dev dependency: ${addDev} ${gate.tools.join(' ')} (the gate runs from the pinned dependency)`
|
|
296
|
-
: `install ${gate.tools.join(', ')} as a dev dependency`
|
|
297
|
-
: gate?.kind === 'script'
|
|
298
|
-
? `add a "${gate.script ?? name}" script to package.json`
|
|
299
|
-
: gate?.kind === 'command'
|
|
300
|
-
? `declare the program under "gates.commands" in ${MANIFEST_FILE}`
|
|
301
|
-
: `remove "${name}" from the gates list in ${MANIFEST_FILE}`;
|
|
302
|
-
// A re-init never re-adds a gate to a manifest that already has one, so "install the
|
|
303
|
-
// tool and re-run init" would send the reader round the loop and back to where they
|
|
304
|
-
// started. On an existing manifest the declaration is the second half of the fix.
|
|
305
|
-
const second =
|
|
306
|
-
npmShaped && foreign
|
|
307
|
-
? `, or add "${name}" to gates.preCommit or gates.prePush in ${MANIFEST_FILE}`
|
|
308
|
-
: existing
|
|
309
|
-
? `, then add "${name}" to gates.preCommit or gates.prePush in ${MANIFEST_FILE}`
|
|
310
|
-
: `, then re-run: ${CLI} init --yes`;
|
|
311
|
-
return `${fix}${second}`;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Is this runner one where `npm run` is not how a gate is spelled?
|
|
316
|
-
*
|
|
317
|
-
* `undefined` means "the caller did not report a runner", and every Node manager
|
|
318
|
-
* (`npm`, `pnpm`, `yarn`, `bun`) spells gates as `<manager> run <script>` with
|
|
319
|
-
* scripts living in the same `package.json`. Only a positively identified
|
|
320
|
-
* non-Node runner changes the wording. (Q31: pnpm is Node-shaped, not foreign.)
|
|
321
|
-
*/
|
|
322
|
-
function isForeignRunner(runner) {
|
|
323
|
-
if (runner == null) return false;
|
|
324
|
-
return !['npm', 'pnpm', 'yarn', 'bun'].includes(runner);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** How a reader would name the runner in a sentence. */
|
|
328
|
-
function runnerName(runner) {
|
|
329
|
-
return (
|
|
330
|
-
{
|
|
331
|
-
none: 'no recognised runner',
|
|
332
|
-
gradle: 'Gradle',
|
|
333
|
-
python: 'Python',
|
|
334
|
-
npm: 'npm',
|
|
335
|
-
pnpm: 'pnpm',
|
|
336
|
-
yarn: 'yarn',
|
|
337
|
-
bun: 'bun',
|
|
338
|
-
cargo: 'Cargo',
|
|
339
|
-
go: 'Go',
|
|
340
|
-
}[runner] ?? runner
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* The stack table, filtered by what this repo can actually run.
|
|
346
|
-
*
|
|
347
|
-
* A declared gate that cannot run is worse than an absent one. Absent, an author sees
|
|
348
|
-
* nothing and knows nothing is being checked; declared-and-unrunnable, the dispatcher
|
|
349
|
-
* fails closed on every commit and the only exit an author can find is `--no-verify`,
|
|
350
|
-
* which teaches the whole team that the gate is theatre. The dispatcher is right to
|
|
351
|
-
* fail closed — the defect is declaring a gate this repo has no way to run.
|
|
352
|
-
*
|
|
353
|
-
* The filter mirrors what the dispatcher actually attempts:
|
|
354
|
-
* - `script` gates run `npm run <script>`, so the script must exist
|
|
355
|
-
* - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
|
|
356
|
-
* installed, not merely declared as a dependency
|
|
357
|
-
* - `command` gates run whatever `gates.commands` declares, so a command gate with
|
|
358
|
-
* no declared command is unrunnable by definition
|
|
359
|
-
*
|
|
360
|
-
* The seeds are returned alongside the plan because a fresh adoption writes them
|
|
361
|
-
* into `gates.commands` and the reader is owed the line that says so: a command
|
|
362
|
-
* that lands in the manifest without appearing in the plan is a silent write.
|
|
363
|
-
*
|
|
364
|
-
* @returns {{gates: {preCommit: string[], prePush: string[]}, dropped: {gate: string, why: string}[], commands: Record<string,string>}}
|
|
365
|
-
*/
|
|
366
|
-
export function planGates(stack = [], capability = null, facts = []) {
|
|
367
|
-
const proposed = defaultGates(stack, facts);
|
|
368
|
-
if (!capability) return { gates: proposed, dropped: [], commands: {} };
|
|
369
|
-
const seeds = ecosystemCommands(facts, capability);
|
|
370
|
-
// A command the repo already declared wins over a seed: the seed is a default,
|
|
371
|
-
// the declaration is a choice, and a default overwriting a choice is a re-init
|
|
372
|
-
// that edits the repo's mind.
|
|
373
|
-
const effective = { ...capability, commands: { ...seeds, ...capability.commands } };
|
|
374
|
-
const dropped = [];
|
|
375
|
-
const runnable = (list) =>
|
|
376
|
-
list.filter((name) => {
|
|
377
|
-
const gate = getGate(name);
|
|
378
|
-
const verdict = gateRunnable(gate, effective);
|
|
379
|
-
if (!verdict.runnable) {
|
|
380
|
-
dropped.push({ gate: name, why: verdict.why, remedy: gateRemedy(gate, { runner: capability.runner }) });
|
|
381
|
-
}
|
|
382
|
-
return verdict.runnable;
|
|
383
|
-
});
|
|
384
|
-
return {
|
|
385
|
-
gates: { preCommit: runnable(proposed.preCommit), prePush: runnable(proposed.prePush) },
|
|
386
|
-
dropped,
|
|
387
|
-
commands: seeds,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* The canonical `gates.commands` a fresh manifest of this repository should carry.
|
|
393
|
-
*
|
|
394
|
-
* Two probes, and both must pass before a command is declared: the fact says the
|
|
395
|
-
* ecosystem is in the repository, and `capability.tools` says its program can run
|
|
396
|
-
* on this machine. A marker file without a toolchain would otherwise declare a
|
|
397
|
-
* gate that fails closed on every commit — the defect ADR-017 exists to prevent,
|
|
398
|
-
* reached from the ecosystem table rather than the stack table.
|
|
399
|
-
*
|
|
400
|
-
* Pure, so `planGates` and `buildManifest` answer with one implementation.
|
|
401
|
-
*/
|
|
402
|
-
function ecosystemCommands(facts = [], capability = null) {
|
|
403
|
-
if (!capability?.tools?.length) return {};
|
|
404
|
-
// A Node runner (npm/pnpm/yarn/bun) already answers "how do I test and format"
|
|
405
|
-
// with its own package.json scripts, and a seed only wins over those scripts when
|
|
406
|
-
// the repo has not declared a command of its own — which is exactly the mixed
|
|
407
|
-
// repos (package.json AND Cargo.toml) where the script is the deliberate answer.
|
|
408
|
-
// Seeding there would replace a choice with a default; the reader's escape hatch
|
|
409
|
-
// is declaring gates.commands by hand, which a seed would lose to anyway. Pure
|
|
410
|
-
// ecosystem repos have runner 'cargo'/'go'/... and seed normally.
|
|
411
|
-
// Q31: every Node manager skips seeding — pnpm is not "foreign", it is Node.
|
|
412
|
-
if (['npm', 'pnpm', 'yarn', 'bun'].includes(capability.runner)) return {};
|
|
413
|
-
const present = new Set(facts);
|
|
414
|
-
const commands = {};
|
|
415
|
-
for (const eco of ECOSYSTEMS) {
|
|
416
|
-
if (!present.has(eco.fact)) continue;
|
|
417
|
-
if (!capability.tools.includes(eco.tool)) continue;
|
|
418
|
-
for (const [gate, command] of Object.entries(eco.commands)) {
|
|
419
|
-
if (!capability.commands?.[gate]) commands[gate] = command;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
return commands;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Build a manifest from a fingerprint. Pure, so the whole default surface is
|
|
427
|
-
* testable without a filesystem.
|
|
428
|
-
*
|
|
429
|
-
* When `capability` is supplied, newly proposed gates the repo cannot run are dropped.
|
|
430
|
-
* Gates the repo ALREADY declared are kept regardless: that is the repo's choice to
|
|
431
|
-
* make, and quietly disarming a gate somebody declared on purpose is a worse surprise
|
|
432
|
-
* than a visible one.
|
|
433
|
-
*/
|
|
434
76
|
export function buildManifest({ root, stack = [], sha, version, existing = null, capability = null, surfaces = undefined, facts = [] }) {
|
|
435
|
-
const gates = defaultGates(stack, facts);
|
|
436
|
-
const limits = defaultLimits(
|
|
77
|
+
const gates = defaultGates(stack, facts, root);
|
|
78
|
+
const limits = defaultLimits();
|
|
437
79
|
|
|
438
80
|
// A re-init must never silently weaken a repo's own declaration: existing
|
|
439
81
|
// limits and gates win over defaults, and only the projection stamp is refreshed.
|
|
440
82
|
const limitsFinal = existing?.limits ? { ...limits, ...existing.limits } : limits;
|
|
441
|
-
const planned = planGates(stack, capability, facts);
|
|
83
|
+
const planned = planGates(stack, capability, facts, root);
|
|
442
84
|
// The seeds land only on a FRESH manifest. On an existing one the repo's
|
|
443
85
|
// declaration is the answer, and adding commands under it would be a re-init
|
|
444
86
|
// writing into a field the reader owns — the same rule as the gates above.
|
|
@@ -544,91 +186,11 @@ function withRepoKeys(existing, owned) {
|
|
|
544
186
|
*
|
|
545
187
|
* @returns {{file: string, status: string, gate: string, detail: string, remedy: string}[]}
|
|
546
188
|
*/
|
|
547
|
-
export function unrunnableGates(manifest, capability) {
|
|
548
|
-
const declared = [
|
|
549
|
-
...(manifest?.gates?.preCommit ?? []),
|
|
550
|
-
...(manifest?.gates?.prePush ?? []),
|
|
551
|
-
];
|
|
552
|
-
const findings = [];
|
|
553
|
-
const seen = new Set();
|
|
554
|
-
// `gates.cwd` names a directory a gate runs in, and a name that is not a declared
|
|
555
|
-
// gate is as much a landmine as one that cannot run: the entry is inert, the reader
|
|
556
|
-
// believes the gate runs in the package it names, and nothing anywhere says so. Same
|
|
557
|
-
// shape as the unknown-gate finding in the dispatcher, so both are repaired the same
|
|
558
|
-
// way.
|
|
559
|
-
const cwds = manifest?.gates?.cwd;
|
|
560
|
-
if (cwds && typeof cwds === 'object' && !Array.isArray(cwds)) {
|
|
561
|
-
for (const name of Object.keys(cwds).sort()) {
|
|
562
|
-
if (declared.includes(name)) continue;
|
|
563
|
-
findings.push({
|
|
564
|
-
file: MANIFEST,
|
|
565
|
-
status: 'unknown-gate-cwd',
|
|
566
|
-
gate: name,
|
|
567
|
-
detail: `gates.cwd names "${name}", which is not declared in gates.preCommit or gates.prePush, so it is never read`,
|
|
568
|
-
remedy: `declare "${name}" in gates.preCommit or gates.prePush, or remove gates.cwd["${name}"]`,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
for (const name of declared) {
|
|
573
|
-
if (seen.has(name)) continue;
|
|
574
|
-
seen.add(name);
|
|
575
|
-
const gate = getGate(name);
|
|
576
|
-
const verdict = gateRunnable(gate, capability);
|
|
577
|
-
if (verdict.runnable) continue;
|
|
578
|
-
findings.push({
|
|
579
|
-
file: MANIFEST,
|
|
580
|
-
status: 'unrunnable',
|
|
581
|
-
gate: name,
|
|
582
|
-
detail: `declares gate "${name}", which this repo cannot run: ${verdict.why}`,
|
|
583
|
-
remedy: gateRemedy(gate, { existing: true, runner: capability.runner }),
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
return findings;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** ADR-005: 400 is a review trigger, 1000 a hard ceiling. Generated files exempt. */
|
|
590
189
|
function defaultLimits() {
|
|
591
190
|
return { coverage: null, fileLines: { warn: 400, error: 1000 } };
|
|
592
191
|
}
|
|
593
192
|
|
|
594
193
|
/** ADR-006: coverage stays null. Set it only if the repo has a real numeric gate. */
|
|
595
|
-
function defaultGates(stack, facts = []) {
|
|
596
|
-
// Tags and facts are one signal set for gate purposes: a row in `GATES_BY_STACK`
|
|
597
|
-
// keys on a tag, a row in `ECOSYSTEMS` on a fact, and the two never name each
|
|
598
|
-
// other's vocabulary (facts are `has-*`, tags are not), so one union cannot
|
|
599
|
-
// cross-match.
|
|
600
|
-
const signals = new Set([...stack, ...facts]);
|
|
601
|
-
const gates = { preCommit: [], prePush: [] };
|
|
602
|
-
|
|
603
|
-
for (const row of GATES_BY_STACK) {
|
|
604
|
-
if (row.when.some((tag) => signals.has(tag))) {
|
|
605
|
-
gates.preCommit.push(...row.preCommit);
|
|
606
|
-
gates.prePush.push(...row.prePush);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
for (const row of ECOSYSTEMS) {
|
|
610
|
-
if (signals.has(row.fact)) {
|
|
611
|
-
// A row proposes what it has: the Maven row has no preCommit gates, so a
|
|
612
|
-
// missing key is "none", not a defect. Spreading undefined throws.
|
|
613
|
-
gates.preCommit.push(...(row.preCommit ?? []));
|
|
614
|
-
gates.prePush.push(...(row.prePush ?? []));
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
for (const row of TAG_GATES) {
|
|
618
|
-
if (signals.has(row.tag)) gates.preCommit.push(...row.preCommit);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// A repo with no recognised stack still gets a gate list, because a gate that
|
|
622
|
-
// runs nothing is indistinguishable from a gate that was never installed.
|
|
623
|
-
if (gates.preCommit.length === 0) gates.preCommit.push('format', 'lint');
|
|
624
|
-
if (gates.prePush.length === 0) gates.prePush.push('test');
|
|
625
|
-
|
|
626
|
-
return { preCommit: dedupe(gates.preCommit), prePush: dedupe(gates.prePush) };
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
function dedupe(list) {
|
|
630
|
-
return [...new Set(list)];
|
|
631
|
-
}
|
|
632
194
|
|
|
633
195
|
/**
|
|
634
196
|
* A repo id is the directory name. Using it rather than a guessed field keeps
|
|
@@ -745,7 +307,9 @@ export function declaredShared(manifest) {
|
|
|
745
307
|
return { level, rules };
|
|
746
308
|
}
|
|
747
309
|
|
|
748
|
-
|
|
310
|
+
// Derived from the table rather than restated: a key this tool writes is a `tool` key
|
|
311
|
+
// marked generated, and a second list here is the drift this module exists to end.
|
|
312
|
+
export const GENERATED_KEYS = GENERATED_KEYS_OF;
|
|
749
313
|
|
|
750
314
|
// The sub-keys of `projection` this tool writes. `projection` is a generated key, but
|
|
751
315
|
// not wholesale: the repository owns sub-keys this writer does not emit (`place` is the
|
|
@@ -892,20 +456,17 @@ export function manifestText(manifest) {
|
|
|
892
456
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
893
457
|
}
|
|
894
458
|
|
|
895
|
-
/**
|
|
459
|
+
/**
|
|
460
|
+
* The manifest in the shape the callers already read, now answered by the one loader
|
|
461
|
+
* (review 0003 Q69). `error` is the unreadable-file case only: a manifest with a value
|
|
462
|
+
* the schema cannot use is still a manifest, and refusing it here would turn every
|
|
463
|
+
* reader into a second validator.
|
|
464
|
+
*
|
|
465
|
+
* @returns {Promise<{manifest: object|null, exists: boolean, error: string|null}>}
|
|
466
|
+
*/
|
|
896
467
|
export async function readManifest(root) {
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
try {
|
|
900
|
-
text = await readFile(path, 'utf8');
|
|
901
|
-
} catch {
|
|
902
|
-
return { manifest: null, exists: false, error: null };
|
|
903
|
-
}
|
|
904
|
-
try {
|
|
905
|
-
return { manifest: JSON.parse(text), exists: true, error: null };
|
|
906
|
-
} catch (error) {
|
|
907
|
-
return { manifest: null, exists: true, error: `unparseable: ${error.message}` };
|
|
908
|
-
}
|
|
468
|
+
const { manifest, exists, problems } = await loadManifest(root);
|
|
469
|
+
return { manifest, exists, error: exists && manifest === null ? (problems[0] ?? 'unparseable') : null };
|
|
909
470
|
}
|
|
910
471
|
|
|
911
472
|
export async function writeManifest(root, manifest, journal = null) {
|
|
@@ -921,4 +482,4 @@ export async function writeManifest(root, manifest, journal = null) {
|
|
|
921
482
|
if (journal) await journaledWriteFile(root, journal, fresh, manifestText(manifest));
|
|
922
483
|
else await writeFile(fresh, manifestText(manifest));
|
|
923
484
|
return fresh;
|
|
924
|
-
}
|
|
485
|
+
}
|
package/src/projection/mdc.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
29
29
|
|
|
30
30
|
import { parse } from './markers.mjs';
|
|
31
31
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
32
|
+
import { parseFrontmatter as splitBlock, stripFrontmatter as stripBlock } from '../text/frontmatter.mjs';
|
|
32
33
|
|
|
33
34
|
// ROOT is computed here rather than imported: project.mjs is the caller of every
|
|
34
35
|
// function below, so importing it back would create a cycle for the sake of one
|
|
@@ -134,9 +135,8 @@ export async function loadMdcRules() {
|
|
|
134
135
|
* rather than skipped, because a silently-dropped substitution projects a rule
|
|
135
136
|
* containing a literal `{{placeholder}}`. */
|
|
136
137
|
function parseSubstitutions(text) {
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
const block = m[1];
|
|
138
|
+
const { front: block } = splitBlock(text);
|
|
139
|
+
if (block === null) return [];
|
|
140
140
|
if (!/^simt-substitutions:/m.test(block)) return [];
|
|
141
141
|
|
|
142
142
|
const out = [];
|
|
@@ -168,10 +168,10 @@ function parseSubstitutions(text) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
function parseFrontmatter(text) {
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
171
|
+
const { front } = splitBlock(text);
|
|
172
|
+
if (front === null) return {};
|
|
173
173
|
const out = {};
|
|
174
|
-
for (const line of
|
|
174
|
+
for (const line of front.split('\n')) {
|
|
175
175
|
// `simt-substitutions:` opens a nested block this parser cannot represent, so
|
|
176
176
|
// it and its list items are skipped here and read by parseSubstitutions.
|
|
177
177
|
if (/^\s*-\s/.test(line)) continue;
|
|
@@ -201,8 +201,7 @@ function unquote(s) {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
function stripFrontmatter(text) {
|
|
204
|
-
return text
|
|
205
|
-
.replace(/^---\n[\s\S]*?\n---\n*/, '')
|
|
204
|
+
return stripBlock(text)
|
|
206
205
|
.replace(/^#\s+.*\n+/, '')
|
|
207
206
|
.trim();
|
|
208
207
|
}
|