simt-dev 0.6.3 → 0.7.1
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 +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.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-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- 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/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// same matcher, so supporting an ecosystem nobody here has heard of requires no
|
|
17
17
|
// code change and no release.
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { constants as fsConstants } from 'node:fs';
|
|
20
|
+
import { access, readFile, readdir, stat } from 'node:fs/promises';
|
|
20
21
|
import { join } from 'node:path';
|
|
21
22
|
|
|
22
23
|
import { baselineTargets, misplaced } from './baselines.mjs';
|
|
@@ -31,8 +32,75 @@ import { measureRoot } from '../measure/context.mjs';
|
|
|
31
32
|
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
32
33
|
import { foldsRules, resolveSurfaces, SURFACES, SURFACE_IDS } from './surfaces.mjs';
|
|
33
34
|
import { pointerState, pointerTargets, ruleTargets, scopedTargets } from './targets.mjs';
|
|
34
|
-
import { CLI } from '../name.mjs';
|
|
35
|
-
import { MANIFEST_FILE } from '../name.mjs';
|
|
35
|
+
import { CLI, LOCK_FILE, LOCK_LEGACY, PACKAGE_NAME } from '../name.mjs';
|
|
36
|
+
import { MANIFEST_FILE, WORKFLOW_FILE_NAME } from '../name.mjs';
|
|
37
|
+
import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
|
|
38
|
+
import { LOCAL_BIN } from '../distribute/release.mjs';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A checked-in gate workflow the locator cannot resolve (review 0003 Q31).
|
|
42
|
+
*
|
|
43
|
+
* Returns a finding, or null when some resolution path can still succeed. Only
|
|
44
|
+
* committed evidence counts: the pinned devDependency, the release binary, and
|
|
45
|
+
* `simt` on this machine's PATH (a global install CI may share — its presence
|
|
46
|
+
* here is a reason to stay silent, its absence is not proof CI lacks it, so the
|
|
47
|
+
* finding also requires the first two to be missing).
|
|
48
|
+
*/
|
|
49
|
+
async function workflowResolvable(root) {
|
|
50
|
+
let pkg = null;
|
|
51
|
+
try {
|
|
52
|
+
pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
pkg = null;
|
|
55
|
+
}
|
|
56
|
+
if (pkg !== null) {
|
|
57
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
58
|
+
if (Object.hasOwn(deps, PACKAGE_NAME)) return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await stat(join(root, LOCAL_BIN));
|
|
62
|
+
return null;
|
|
63
|
+
} catch {
|
|
64
|
+
// No release binary: keep checking the last path.
|
|
65
|
+
}
|
|
66
|
+
for (const dir of (process.env.PATH ?? '').split(':').filter(Boolean)) {
|
|
67
|
+
try {
|
|
68
|
+
await access(join(dir, 'simt'), fsConstants.X_OK);
|
|
69
|
+
return null;
|
|
70
|
+
} catch {
|
|
71
|
+
// Not here; keep looking.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const file = `.github/workflows/${WORKFLOW_FILE_NAME}`;
|
|
75
|
+
if (pkg !== null) {
|
|
76
|
+
const lockfiles = [];
|
|
77
|
+
for (const name of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
|
|
78
|
+
try {
|
|
79
|
+
await stat(join(root, name));
|
|
80
|
+
lockfiles.push(name);
|
|
81
|
+
} catch {
|
|
82
|
+
// Absent is the normal case.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const runner = resolveNodeRunner({
|
|
86
|
+
lockfiles,
|
|
87
|
+
packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
file,
|
|
91
|
+
status: 'unresolvable',
|
|
92
|
+
detail: `the checked-in gate workflow looks for ${PACKAGE_NAME} in node_modules, but nothing it tries can succeed: no pinned devDependency, no ${LOCAL_BIN}, no simt on PATH`,
|
|
93
|
+
remedy: `run: ${runner.addDev} ${PACKAGE_NAME} (the gate runs from the pinned dependency)`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
file,
|
|
98
|
+
status: 'unresolvable',
|
|
99
|
+
detail: `the checked-in gate workflow cannot resolve ${PACKAGE_NAME}: no package.json to pin it in, no ${LOCAL_BIN}, no simt on PATH`,
|
|
100
|
+
remedy: `install the pinned gate binary into the repository, then re-run: simt init --yes`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
import { computeLock } from './lock.mjs';
|
|
36
104
|
import {
|
|
37
105
|
desiredRegion,
|
|
38
106
|
provenanceDigest,
|
|
@@ -53,6 +121,16 @@ import {
|
|
|
53
121
|
* most agents, including ones that ignore .cursor/ entirely. */
|
|
54
122
|
export const PRIMARY_TARGET = 'AGENTS.md';
|
|
55
123
|
|
|
124
|
+
/** The gate's verdict on a region `sync` has already written. `current` rows carry
|
|
125
|
+
* `detail: null`, not `undefined`: a key whose value is `undefined` survives into the
|
|
126
|
+
* structured MCP surface but is dropped by `JSON.stringify`, so the two surfaces of one
|
|
127
|
+
* answer no longer deep-equal. */
|
|
128
|
+
function regionVerdict(action) {
|
|
129
|
+
return action === 'unchanged'
|
|
130
|
+
? { status: 'current', detail: null }
|
|
131
|
+
: { status: 'drift', detail: `region is ${action}; run: ${CLI} sync --yes` };
|
|
132
|
+
}
|
|
133
|
+
|
|
56
134
|
|
|
57
135
|
/**
|
|
58
136
|
* @param {string} root repo root
|
|
@@ -62,7 +140,7 @@ export const PRIMARY_TARGET = 'AGENTS.md';
|
|
|
62
140
|
export async function plan(root, flags = {}) {
|
|
63
141
|
const desired = await desiredRegion(root, flags);
|
|
64
142
|
const { stack, facts, profiles, fallback, notes, body, meta, all, manifest } = desired;
|
|
65
|
-
const target = await targetFor(root, body, meta);
|
|
143
|
+
const target = await targetFor(root, root, body, meta);
|
|
66
144
|
|
|
67
145
|
// Per-rule content projects to every editor the repo selected, not only Cursor. The
|
|
68
146
|
// body is identical on each; only the frontmatter the editor reads before any rule
|
|
@@ -94,9 +172,39 @@ export async function plan(root, flags = {}) {
|
|
|
94
172
|
// root's would read as though the packages were the repository.
|
|
95
173
|
const packages = await packageRegions(root, flags, profiles);
|
|
96
174
|
const nested = await Promise.all(
|
|
97
|
-
packages.regions.map((r) => targetFor(join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
|
|
175
|
+
packages.regions.map((r) => targetFor(root, join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
|
|
98
176
|
);
|
|
99
177
|
|
|
178
|
+
// The lock records the two digests a frozen check compares (ADR-086). It is a target
|
|
179
|
+
// like every other: `plan` produces it, so preview, `--diff`, the journal, the idle
|
|
180
|
+
// test and `init` all inherit it. Computing it here rather than in `sync` is what makes
|
|
181
|
+
// a deleted lock show up in the plan as a write instead of vanishing from it — the
|
|
182
|
+
// `sync` that printed "wrote simt-dev.lock.json" and then "nothing written" (Q29).
|
|
183
|
+
const lock = await computeLock(root, flags);
|
|
184
|
+
const lockText = `${JSON.stringify(lock, null, 2)}\n`;
|
|
185
|
+
let previousLockText = '';
|
|
186
|
+
let lockExists = false;
|
|
187
|
+
for (const name of [LOCK_FILE, LOCK_LEGACY]) {
|
|
188
|
+
try {
|
|
189
|
+
previousLockText = await readFile(join(root, name), 'utf8');
|
|
190
|
+
lockExists = true;
|
|
191
|
+
break;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.code !== 'ENOENT') throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const lockTarget = {
|
|
197
|
+
file: LOCK_FILE,
|
|
198
|
+
path: join(root, LOCK_FILE),
|
|
199
|
+
kind: 'lock',
|
|
200
|
+
surface: null,
|
|
201
|
+
exists: lockExists,
|
|
202
|
+
action: !lockExists ? 'create' : previousLockText === lockText ? 'unchanged' : 'update',
|
|
203
|
+
bytes: Buffer.byteLength(lockText),
|
|
204
|
+
previousBytes: Buffer.byteLength(previousLockText),
|
|
205
|
+
text: lockText,
|
|
206
|
+
};
|
|
207
|
+
|
|
100
208
|
return {
|
|
101
209
|
root,
|
|
102
210
|
stack,
|
|
@@ -107,15 +215,25 @@ export async function plan(root, flags = {}) {
|
|
|
107
215
|
surfaceSource: surfaces.source,
|
|
108
216
|
surfaceProblems: surfaces.problems,
|
|
109
217
|
rules: all,
|
|
110
|
-
targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets],
|
|
218
|
+
targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets, lockTarget],
|
|
111
219
|
packages: packages.regions.map((r) => ({ path: r.path, profiles: r.profiles })),
|
|
112
220
|
mdcProblems: mdc.problems,
|
|
221
|
+
crossRefs: desired.crossRefs,
|
|
113
222
|
notes: [...notes, ...mdc.notes, ...packages.notes],
|
|
114
223
|
};
|
|
115
224
|
}
|
|
116
225
|
|
|
117
|
-
|
|
118
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Build the write target for one AGENTS.md. `root` is the repository: `regionOpts`
|
|
228
|
+
* reads its manifest, because placement is a repository-level decision that holds for
|
|
229
|
+
* every package's region too. `dir` is where the file lives, which differs from
|
|
230
|
+
* `root` only for a nested monorepo package (review 0003 Q26).
|
|
231
|
+
*
|
|
232
|
+
* @param {string} root repo root
|
|
233
|
+
* @param {string} dir directory the target file lives in
|
|
234
|
+
*/
|
|
235
|
+
async function targetFor(root, dir, body, meta, label = PRIMARY_TARGET) {
|
|
236
|
+
const path = join(dir, PRIMARY_TARGET);
|
|
119
237
|
let existing = '';
|
|
120
238
|
let exists = true;
|
|
121
239
|
try {
|
|
@@ -186,8 +304,24 @@ export async function checkTargets(root, flags = {}) {
|
|
|
186
304
|
// Two `.github` files, one ownership rule, so one loop over both. The gate and the
|
|
187
305
|
// dependency-updates config fail for the same reason and are fixed the same way,
|
|
188
306
|
// and listing them separately is how the second copy of this rule gets written.
|
|
307
|
+
const workflowPlan = await planWorkflow(root);
|
|
308
|
+
// Q31: a workflow that exists but cannot resolve simt-dev by any path the
|
|
309
|
+
// locator tries is a green tick on a gate that never runs. The locator tries
|
|
310
|
+
// `$SIMT_DEV_BIN`, the pinned devDependency, the release binary and `simt`
|
|
311
|
+
// on PATH, in that order; only the middle two are committed evidence this
|
|
312
|
+
// check can see. When the repo pins nothing and carries no binary, the check
|
|
313
|
+
// can still see `simt` on its own PATH — a global install CI may share — so
|
|
314
|
+
// it stays silent then rather than crying wolf about a resolution it cannot
|
|
315
|
+
// disprove. The remedy names the repo's own manager, never a bare npm guess.
|
|
316
|
+
// simt-dev's own copy is exempt: it runs this checkout directly and never asks the
|
|
317
|
+
// locator to find a binary, so a machine without `simt` on PATH is not evidence that
|
|
318
|
+
// the gate cannot start. The consumer copy is not exempt, because its locator does.
|
|
319
|
+
if (!workflowPlan.own && workflowPlan.action !== 'create' && workflowPlan.action !== 'skip') {
|
|
320
|
+
const unresolvable = await workflowResolvable(root);
|
|
321
|
+
if (unresolvable) findings.push(unresolvable);
|
|
322
|
+
}
|
|
189
323
|
for (const [plan, what] of [
|
|
190
|
-
[
|
|
324
|
+
[workflowPlan, 'nothing gates it'],
|
|
191
325
|
[await planDependabot(root), 'nothing watches its dependencies'],
|
|
192
326
|
]) {
|
|
193
327
|
if (plan.action === 'create') {
|
|
@@ -258,7 +392,20 @@ export async function checkTargets(root, flags = {}) {
|
|
|
258
392
|
for (const p of resolved.problems) {
|
|
259
393
|
findings.push({ file: `${MANIFEST_FILE}`, status: 'invalid', detail: p, remedy: `known surfaces: ${SURFACE_IDS.join(', ')}` });
|
|
260
394
|
}
|
|
261
|
-
const
|
|
395
|
+
const signals = new Set([...stack, ...facts]);
|
|
396
|
+
const rules = all.filter((r) => isEligible(r, signals, declared.optIn));
|
|
397
|
+
|
|
398
|
+
// A dangling cross-reference is a rule-text defect: the region renders, but it points
|
|
399
|
+
// the reader at a profile they cannot reach. A gate failure, like any other rule defect.
|
|
400
|
+
for (const p of desired.crossRefs ?? []) {
|
|
401
|
+
const idx = p.indexOf(': ');
|
|
402
|
+
findings.push({
|
|
403
|
+
file: p.slice(0, idx),
|
|
404
|
+
status: 'invalid',
|
|
405
|
+
detail: p.slice(idx + 2),
|
|
406
|
+
remedy: `co-select the profile, or name it with ${CLI} rules show <id>`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
262
409
|
|
|
263
410
|
const sha = await provenanceDigest();
|
|
264
411
|
const notes = [];
|
|
@@ -286,8 +433,7 @@ export async function checkTargets(root, flags = {}) {
|
|
|
286
433
|
findings.push({
|
|
287
434
|
file: target.file,
|
|
288
435
|
surface: target.surface,
|
|
289
|
-
|
|
290
|
-
detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
|
|
436
|
+
...regionVerdict(action),
|
|
291
437
|
recordedSha: parse(target.previous).sha ?? null,
|
|
292
438
|
});
|
|
293
439
|
}
|
|
@@ -308,6 +454,7 @@ export async function checkTargets(root, flags = {}) {
|
|
|
308
454
|
const { body, problems } = renderMdc(
|
|
309
455
|
rule,
|
|
310
456
|
ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
|
|
457
|
+
signals,
|
|
311
458
|
);
|
|
312
459
|
if (problems.length > 0) {
|
|
313
460
|
findings.push({
|
|
@@ -359,8 +506,7 @@ export async function checkTargets(root, flags = {}) {
|
|
|
359
506
|
findings.push({
|
|
360
507
|
file: rel,
|
|
361
508
|
surface: target.surface,
|
|
362
|
-
|
|
363
|
-
detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
|
|
509
|
+
...regionVerdict(action),
|
|
364
510
|
recordedSha: state.sha ?? null,
|
|
365
511
|
});
|
|
366
512
|
}
|
|
@@ -395,8 +541,7 @@ export async function checkTargets(root, flags = {}) {
|
|
|
395
541
|
}
|
|
396
542
|
findings.push({
|
|
397
543
|
file: t.file,
|
|
398
|
-
|
|
399
|
-
detail: t.action === 'unchanged' ? undefined : `region is ${t.action}; run: ${CLI} sync --yes`,
|
|
544
|
+
...regionVerdict(t.action),
|
|
400
545
|
recordedSha: state.sha ?? null,
|
|
401
546
|
});
|
|
402
547
|
const off = misplaced(state, t.style);
|
|
@@ -566,8 +711,7 @@ async function checkRegion(root, dir, body, meta) {
|
|
|
566
711
|
const d = diff(existing, body, meta, await regionOpts(root));
|
|
567
712
|
return {
|
|
568
713
|
file: PRIMARY_TARGET,
|
|
569
|
-
|
|
570
|
-
detail: d.action === 'unchanged' ? undefined : `region is ${d.action}; run: ${CLI} sync --yes`,
|
|
714
|
+
...regionVerdict(d.action),
|
|
571
715
|
recordedSha: state.sha ?? null,
|
|
572
716
|
expectedSha: meta.sha,
|
|
573
717
|
};
|
package/src/projection/rules.mjs
CHANGED
|
@@ -27,17 +27,18 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
27
27
|
|
|
28
28
|
import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
|
|
29
29
|
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
30
|
-
import { foldsRules,
|
|
30
|
+
import { foldsRules, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
|
|
31
31
|
import { ruleTargets } from './targets.mjs';
|
|
32
32
|
import { compose } from './markers.mjs';
|
|
33
|
-
import { declaredBudget, declaredShared,
|
|
33
|
+
import { declaredBudget, declaredShared, readManifest } from './manifest.mjs';
|
|
34
|
+
import { loadManifest } from '../manifest/schema.mjs';
|
|
34
35
|
import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
|
|
35
36
|
import { packToBudget } from '../kernel/budget.mjs';
|
|
36
37
|
import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
|
|
37
38
|
import { checkDefect, foldChecks } from '../kernel/checks.mjs';
|
|
38
39
|
import { foldVariants, readLockedVariants, variantsDefect } from '../kernel/variants.mjs';
|
|
39
40
|
import { BASELINES } from './baselines.mjs';
|
|
40
|
-
import { CLI } from '../name.mjs';
|
|
41
|
+
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
41
42
|
import { installedSkills } from './disclosure.mjs';
|
|
42
43
|
import { PACK_DIR, resolvePacks } from '../packs.mjs';
|
|
43
44
|
import { discoverPackages } from '../monorepo.mjs';
|
|
@@ -134,6 +135,10 @@ export async function provenanceInputs() {
|
|
|
134
135
|
async function walk(dir, prefix = '') {
|
|
135
136
|
const out = [];
|
|
136
137
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
138
|
+
// A dotfile is machine noise (.DS_Store on macOS), not rule content: the digest
|
|
139
|
+
// must be stable across checkouts, and a fresh worktree does not carry the
|
|
140
|
+
// author's Finder metadata. Rule files are .md/.mdc, never dotfiles.
|
|
141
|
+
if (entry.name.startsWith('.')) continue;
|
|
137
142
|
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
138
143
|
if (entry.isDirectory()) out.push(...(await walk(join(dir, entry.name), rel)));
|
|
139
144
|
else if (entry.isFile()) out.push(rel);
|
|
@@ -251,7 +256,7 @@ export function isEligible(rule, signals, optedIn) {
|
|
|
251
256
|
export async function readDeclared(root) {
|
|
252
257
|
let parsed = {};
|
|
253
258
|
try {
|
|
254
|
-
parsed =
|
|
259
|
+
parsed = (await loadManifest(root)).manifest ?? {};
|
|
255
260
|
} catch {
|
|
256
261
|
parsed = {};
|
|
257
262
|
}
|
|
@@ -282,8 +287,11 @@ export async function mdcTargets(root, sha, manifest, flags) {
|
|
|
282
287
|
// same `bodies` from the same call, so the region and the files cannot disagree.
|
|
283
288
|
if (foldsRules(resolved) && !resolved.surfaces.some((s) => s.rule)) {
|
|
284
289
|
if (bodies.length > 0) {
|
|
290
|
+
// An empty selection has no row to read the primary from; the document is the
|
|
291
|
+
// one the region lives in, which is the answer in every case this note fires.
|
|
292
|
+
const primary = resolved.surfaces[0]?.primary ?? 'AGENTS.md';
|
|
285
293
|
notes.push(
|
|
286
|
-
`every rule is folded into ${
|
|
294
|
+
`every rule is folded into ${primary}; add a surface with its own rule path for a file per rule`,
|
|
287
295
|
);
|
|
288
296
|
}
|
|
289
297
|
return { targets, problems, notes };
|
|
@@ -411,6 +419,7 @@ async function ruleBodies(root, manifest, flags) {
|
|
|
411
419
|
const { body, problems: renderProblems } = renderMdc(
|
|
412
420
|
rule,
|
|
413
421
|
ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
|
|
422
|
+
signals,
|
|
414
423
|
);
|
|
415
424
|
for (const p of renderProblems) problems.push(`${rule.file}: ${p}`);
|
|
416
425
|
if (renderProblems.length > 0) continue;
|
|
@@ -558,6 +567,16 @@ export async function desiredRegion(root, flags) {
|
|
|
558
567
|
for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
|
|
559
568
|
shipped.push({ ...r, body: folded.body });
|
|
560
569
|
}
|
|
570
|
+
// A body that points the reader at a profile they cannot reach is a rule-text defect:
|
|
571
|
+
// the region renders, but the reference dangles. Kept out of `problems` because that
|
|
572
|
+
// array is the mdc list `sync` already reports, and one fault must not print twice.
|
|
573
|
+
const crossRefs = [];
|
|
574
|
+
const selectedIds = new Set(selected.map((r) => r.id));
|
|
575
|
+
for (const r of shipped) {
|
|
576
|
+
for (const p of crossRefProblems(r.body, all, selectedIds)) {
|
|
577
|
+
crossRefs.push(`${r.id}: ${p}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
561
580
|
const lockedNonDefault = variantsSelected.filter((v) => v.locked && v.winner !== v.baseline);
|
|
562
581
|
if (lockedNonDefault.length > 0) {
|
|
563
582
|
notes.push(
|
|
@@ -906,6 +925,7 @@ export async function desiredRegion(root, flags) {
|
|
|
906
925
|
surfaces: resolved.ids,
|
|
907
926
|
surfaceSource: resolved.source,
|
|
908
927
|
problems,
|
|
928
|
+
crossRefs,
|
|
909
929
|
fallback,
|
|
910
930
|
notes,
|
|
911
931
|
body: assembled.body,
|
|
@@ -1301,9 +1321,13 @@ export function byPriorityThenId(a, b) {
|
|
|
1301
1321
|
* `Applies to` is already the first line of a stack profile once its H1 is gone,
|
|
1302
1322
|
* so it lands directly under `## Stack: …` instead of under whatever section the
|
|
1303
1323
|
* previous source ended on.
|
|
1324
|
+
*
|
|
1325
|
+
* The rule text names the manifest with a `{manifest}` token and the render
|
|
1326
|
+
* substitutes it, so a rename of the file is a change in one place (MANIFEST_FILE),
|
|
1327
|
+
* not a search across every rule body.
|
|
1304
1328
|
*/
|
|
1305
1329
|
function frameSource(rule, body, kind) {
|
|
1306
|
-
const text = demoteHeadings(body).trim();
|
|
1330
|
+
const text = demoteHeadings(body.replaceAll('{manifest}', MANIFEST_FILE)).trim();
|
|
1307
1331
|
if (!text) return '';
|
|
1308
1332
|
const title = sourceTitle(rule, kind);
|
|
1309
1333
|
if (!title) return text;
|
|
@@ -1317,6 +1341,34 @@ function sourceTitle(rule, kind) {
|
|
|
1317
1341
|
return `Stack: ${rule.id}`;
|
|
1318
1342
|
}
|
|
1319
1343
|
|
|
1344
|
+
/**
|
|
1345
|
+
* A body may point the reader at another profile in two ways: naming a profile
|
|
1346
|
+
* that is selected alongside it, or giving the command to fetch one
|
|
1347
|
+
* (`simt rules show <id>`). A reference that is neither is one the reader of
|
|
1348
|
+
* the projected region cannot follow. Both citation forms are checked: the
|
|
1349
|
+
* backtick-quoted `<id> profile` in prose, and the `simt rules show <id>`
|
|
1350
|
+
* command itself - an id that form names must exist, or the command 404s.
|
|
1351
|
+
*/
|
|
1352
|
+
function crossRefProblems(body, all, selectedIds) {
|
|
1353
|
+
const problems = [];
|
|
1354
|
+
const seen = new Set();
|
|
1355
|
+
for (const m of String(body).matchAll(/`([a-z0-9][a-z0-9-]*)`\s+profile|rules show ([a-z0-9][a-z0-9-]*)/g)) {
|
|
1356
|
+
const id = m[1] ?? m[2];
|
|
1357
|
+
if (seen.has(id)) continue;
|
|
1358
|
+
seen.add(id);
|
|
1359
|
+
if (!all.some((r) => r.id === id)) {
|
|
1360
|
+
problems.push(`cross-reference: body cites the \`${id}\` profile, which is not a declared rule`);
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
if (!selectedIds.has(id) && !new RegExp(`${CLI} rules show ${id}`).test(body)) {
|
|
1364
|
+
problems.push(
|
|
1365
|
+
`cross-reference: body cites the \`${id}\` profile, which is neither co-selected nor reachable via \`${CLI} rules show ${id}\``,
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return problems;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1320
1372
|
/** `##` becomes `###`. A fence is left alone: a heading in an example is an example. */
|
|
1321
1373
|
function demoteHeadings(text) {
|
|
1322
1374
|
let fence = false;
|
|
@@ -327,12 +327,20 @@ export const DEFAULT_SURFACES = ['opencode'];
|
|
|
327
327
|
* Cursor reader gets them as `.mdc`. Folding and per-rule files therefore coexist,
|
|
328
328
|
* and `ruleTargets` already skips a surface that has no rule path.
|
|
329
329
|
*
|
|
330
|
+
* An empty selection folds too: with no surface carrying a rule path, the region is
|
|
331
|
+
* the only channel a rule has, and the old answer (no surface folds, so nothing
|
|
332
|
+
* folds) wrote every process rule nowhere while `check` reported current (review
|
|
333
|
+
* 0003 Q27).
|
|
334
|
+
*
|
|
330
335
|
* Asked in exactly two places that must agree: `desiredRegion` folds the bodies in,
|
|
331
336
|
* and `mdcTargets` writes the files. Asking it twice is how ADR-020's fold decision
|
|
332
337
|
* could drift between the writer and the gate, which is the step-6 failure.
|
|
333
338
|
*/
|
|
334
339
|
export function foldsRules(resolved) {
|
|
335
|
-
|
|
340
|
+
if (resolved.surfaces.length === 0) return true;
|
|
341
|
+
// A surface with no rule path has no per-rule channel, so its rules must be in the
|
|
342
|
+
// region; `foldRules` is that fact recorded on the row.
|
|
343
|
+
return resolved.surfaces.some((s) => s.foldRules === true || !s.rule);
|
|
336
344
|
}
|
|
337
345
|
|
|
338
346
|
/**
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The repository root a working directory belongs to, for the hook path.
|
|
2
|
+
//
|
|
3
|
+
// A payload's `cwd` is the directory the harness happens to be in, and a harness
|
|
4
|
+
// happens to be in subdirectories: an agent that was told to work in `src/` edits
|
|
5
|
+
// from there, and every repository-relative comparison that resolves against the
|
|
6
|
+
// payload's cwd — the lease file, `.gitmodules`, the manifest — compares against
|
|
7
|
+
// the wrong root and silently stops matching (review 0003 Q16). The root this
|
|
8
|
+
// module answers is the one git itself would use, so a lease taken at the top
|
|
9
|
+
// level matches an edit made three directories down.
|
|
10
|
+
//
|
|
11
|
+
// The answer is synchronous because the caller is a hook on the hot path: one
|
|
12
|
+
// `git rev-parse` is cheaper than the repository walk the fallback would do in
|
|
13
|
+
// the common case, and the fallback only pays when git cannot answer at all —
|
|
14
|
+
// a directory that is not in a repository, where the walk is short by
|
|
15
|
+
// definition.
|
|
16
|
+
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { MANIFEST_FILE, STATE_DIR } from './name.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The top-level directory of the repository `cwd` is in, or `cwd` itself when it
|
|
25
|
+
* is in none.
|
|
26
|
+
*
|
|
27
|
+
* The fallback looks for the two facts that mark a directory as adopted by this
|
|
28
|
+
* tool — the state directory and the manifest — and walks up, because a hook
|
|
29
|
+
* fired from a subdirectory of an adopted repository must still find it: a
|
|
30
|
+
* repository with no `.git` at all (a worktree's parent, a fresh checkout
|
|
31
|
+
// before `git init` finishes) is the case the git call refuses and the walk
|
|
32
|
+
* answers.
|
|
33
|
+
*/
|
|
34
|
+
export function repoRoot(cwd) {
|
|
35
|
+
const start = resolve(String(cwd ?? ''));
|
|
36
|
+
const result = spawnSync('git', ['-C', start, 'rev-parse', '--show-toplevel'], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
timeout: 2000,
|
|
39
|
+
});
|
|
40
|
+
if (result.status === 0) {
|
|
41
|
+
const top = String(result.stdout ?? '').trim();
|
|
42
|
+
if (top !== '') return top;
|
|
43
|
+
}
|
|
44
|
+
let dir = start;
|
|
45
|
+
for (;;) {
|
|
46
|
+
if (existsSync(join(dir, STATE_DIR)) || existsSync(join(dir, MANIFEST_FILE))) return dir;
|
|
47
|
+
const parent = dirname(dir);
|
|
48
|
+
if (parent === dir) return start;
|
|
49
|
+
dir = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The directory a command works on: `--root`, else the first positional when the command
|
|
55
|
+
* takes one, else the current directory.
|
|
56
|
+
*
|
|
57
|
+
* This precedence is a user-visible contract, not an implementation detail: it decides
|
|
58
|
+
* which directory `--root .` and a bare positional mean, and thirty-four commands had
|
|
59
|
+
* their own copy of it. The copies drifted - some resolved the answer, some did not; some
|
|
60
|
+
* read `args[0]`, some `positional[0]` - so the same argv could name two directories
|
|
61
|
+
* depending on which command read it (review 0003 Q65).
|
|
62
|
+
*
|
|
63
|
+
* The answer is returned verbatim, not resolved: a caller that wants an absolute path
|
|
64
|
+
* says so, and a command that reports the root it was given still reports what the reader
|
|
65
|
+
* typed. `repoRoot` answers the different question of which repository that directory
|
|
66
|
+
* belongs to.
|
|
67
|
+
*/
|
|
68
|
+
export function commandRoot(flags = {}, positional = []) {
|
|
69
|
+
return flags.root ?? positional[0] ?? process.cwd();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether git on this repository compares paths case-insensitively, which is
|
|
74
|
+
* the one filesystem fact a path comparison must honour: on a case-insensitive
|
|
75
|
+
* volume `NB.ipynb` and `nb.ipynb` are the file, and a comparison that spells
|
|
76
|
+
* them differently is a comparison that misses.
|
|
77
|
+
*
|
|
78
|
+
* A git that cannot answer (no repository, no binary) answers `false`, which is
|
|
79
|
+
* the strict spelling: the two case variants are then treated as distinct, the
|
|
80
|
+
// way they are on the filesystem the comparison is being done for.
|
|
81
|
+
*/
|
|
82
|
+
export function coreIgnoreCase(root) {
|
|
83
|
+
const result = spawnSync('git', ['-C', root, 'config', '--get', 'core.ignorecase'], {
|
|
84
|
+
encoding: 'utf8',
|
|
85
|
+
timeout: 1000,
|
|
86
|
+
});
|
|
87
|
+
return result.status === 0 && String(result.stdout ?? '').trim().toLowerCase() === 'true';
|
|
88
|
+
}
|
package/src/resolve/body.mjs
CHANGED
|
@@ -22,6 +22,10 @@ import { existsSync } from 'node:fs';
|
|
|
22
22
|
import { locateSource, sourceHeldLocally } from './source.mjs';
|
|
23
23
|
|
|
24
24
|
export async function sourcePath(skill, workspaceRoot, root) {
|
|
25
|
+
// A node synthesized from the repo's own skill directory already names its file.
|
|
26
|
+
// Asking the workspace for it would hunt for a body the registry never declared.
|
|
27
|
+
if (skill.localFile) return skill.localFile;
|
|
28
|
+
|
|
25
29
|
const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
|
|
26
30
|
|
|
27
31
|
if (canonical) {
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
|
|
20
20
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
|
-
import {
|
|
22
|
+
import { join } from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { matchesGlob } from './resolve.mjs';
|
|
25
25
|
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
26
|
+
import { loadManifest } from '../manifest/schema.mjs';
|
|
26
27
|
|
|
27
28
|
/** tag -> human-readable marker(s) that imply it. First match wins per tag. */
|
|
28
29
|
const MARKERS = [
|
|
@@ -180,6 +181,11 @@ const FACTS = [
|
|
|
180
181
|
{ name: 'has-mix', kind: 'file', paths: ['mix.exs'] },
|
|
181
182
|
{ name: 'has-deno-config', kind: 'file', paths: ['deno.json', 'deno.jsonc'] },
|
|
182
183
|
{ name: 'has-bun-lock', kind: 'file', paths: ['bun.lock', 'bun.lockb'] },
|
|
184
|
+
// Q31: one fact per Node lockfile, so the runner resolver, the card, the gate
|
|
185
|
+
// seeds and CI all read the same committed evidence instead of assuming npm.
|
|
186
|
+
{ name: 'has-pnpm-lock', kind: 'file', paths: ['pnpm-lock.yaml'] },
|
|
187
|
+
{ name: 'has-yarn-lock', kind: 'file', paths: ['yarn.lock'] },
|
|
188
|
+
{ name: 'has-package-lock', kind: 'file', paths: ['package-lock.json'] },
|
|
183
189
|
// A glob walk skips dot directories, and `.devcontainer` is one. Stat the path.
|
|
184
190
|
{ name: 'has-devcontainer', kind: 'path', paths: ['.devcontainer/devcontainer.json'] },
|
|
185
191
|
];
|
|
@@ -220,6 +226,12 @@ export async function fingerprint(root) {
|
|
|
220
226
|
}
|
|
221
227
|
const names = new Set(entries);
|
|
222
228
|
|
|
229
|
+
// One walk answers every `glob` fact. Each candidate used to walk the tree again, so
|
|
230
|
+
// a repository with seven globs paid for seven identical walks - the same directories,
|
|
231
|
+
// in the same order, for the same answer (review 0003 Q67).
|
|
232
|
+
let nested = null;
|
|
233
|
+
const nestedPaths = async () => (nested ??= await listNested(root));
|
|
234
|
+
|
|
223
235
|
for (const [tag, markers] of MARKERS) {
|
|
224
236
|
const hit = markers.find((m) => names.has(m));
|
|
225
237
|
if (hit) {
|
|
@@ -244,7 +256,7 @@ export async function fingerprint(root) {
|
|
|
244
256
|
break;
|
|
245
257
|
}
|
|
246
258
|
if (fact.kind === 'glob') {
|
|
247
|
-
const hit = (await
|
|
259
|
+
const hit = findNestedIn(await nestedPaths(), candidate)[0];
|
|
248
260
|
if (hit) {
|
|
249
261
|
facts.add(fact.name);
|
|
250
262
|
factEvidence[fact.name] ??= hit;
|
|
@@ -315,9 +327,9 @@ export async function fingerprint(root) {
|
|
|
315
327
|
: names.has(MANIFEST_LEGACY)
|
|
316
328
|
? MANIFEST_LEGACY
|
|
317
329
|
: null;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
330
|
+
// One reader, so a file that does not parse is a missing declaration rather than an
|
|
331
|
+
// object whose only key is "error" pretending to be this repo manifest (Q69).
|
|
332
|
+
const manifest = manifestName === null ? null : (await loadManifest(root)).manifest;
|
|
321
333
|
|
|
322
334
|
const stackList = [...stack].sort();
|
|
323
335
|
const factList = [...facts].sort();
|
|
@@ -334,14 +346,6 @@ export async function fingerprint(root) {
|
|
|
334
346
|
};
|
|
335
347
|
}
|
|
336
348
|
|
|
337
|
-
async function readManifest(path) {
|
|
338
|
-
try {
|
|
339
|
-
return JSON.parse(await readFile(path, 'utf8'));
|
|
340
|
-
} catch {
|
|
341
|
-
return { error: `${basename(path)} unparseable` };
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
349
|
async function isDir(path) {
|
|
346
350
|
try {
|
|
347
351
|
return (await stat(path)).isDirectory();
|
|
@@ -359,7 +363,7 @@ async function isDir(path) {
|
|
|
359
363
|
* are skipped for the reason the extension sample skips them: detection is not a
|
|
360
364
|
* scan of vendored output.
|
|
361
365
|
*/
|
|
362
|
-
async function
|
|
366
|
+
async function listNested(root) {
|
|
363
367
|
const found = [];
|
|
364
368
|
const walk = async (dir, prefix, depth) => {
|
|
365
369
|
let items;
|
|
@@ -373,7 +377,7 @@ async function findNested(root, pattern) {
|
|
|
373
377
|
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
374
378
|
if (item.isDirectory()) {
|
|
375
379
|
if (depth < GLOB_DEPTH) await walk(join(dir, item.name), rel, depth + 1);
|
|
376
|
-
} else
|
|
380
|
+
} else {
|
|
377
381
|
found.push(rel);
|
|
378
382
|
}
|
|
379
383
|
}
|
|
@@ -382,6 +386,11 @@ async function findNested(root, pattern) {
|
|
|
382
386
|
return found.sort();
|
|
383
387
|
}
|
|
384
388
|
|
|
389
|
+
/** The paths in the one walk that match a shape. */
|
|
390
|
+
function findNestedIn(paths, pattern) {
|
|
391
|
+
return paths.filter((rel) => matchesGlob(rel, pattern)).sort();
|
|
392
|
+
}
|
|
393
|
+
|
|
385
394
|
/**
|
|
386
395
|
* Which extensions this repository is written in.
|
|
387
396
|
*
|