simt-dev 0.6.2 → 0.7.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 +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.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-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- 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/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- 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 +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- 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 +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- 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 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- 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 +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- 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 +21 -14
- package/src/projection/manifest.mjs +82 -21
- 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 +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// `simt doctor [--fix] [--yes] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Step 3 of the implementation sequence: classify a repo from marker files alone.
|
|
4
|
+
// Deliberately shipped early — it needs no registry, no rules and no projection,
|
|
5
|
+
// and it makes the tool useful before any content exists.
|
|
6
|
+
//
|
|
7
|
+
// Plan 0003 R47 adds the findings table and `--fix`. Every finding carries its
|
|
8
|
+
// remedy and whether that remedy is flagged safe, and `--fix` applies only the
|
|
9
|
+
// safe ones while listing the rest with the reason each needs a human:
|
|
10
|
+
//
|
|
11
|
+
// - safe means local, idempotent and journaled (the R49 journal is the undo).
|
|
12
|
+
// `sync`'s manifest repair, a hooks install over routing this tool already
|
|
13
|
+
// owns, and the journal's own .gitignore entry all qualify.
|
|
14
|
+
// - safe is necessary but not sufficient: the fix must also be a REPAIR, not
|
|
15
|
+
// an ADOPTION. `init` on a manifest-less repo is local, idempotent and
|
|
16
|
+
// journaled, and still flagged unsafe — adopting a repo chooses its rules,
|
|
17
|
+
// regions and CI, and the preview of that choice is what the operator must
|
|
18
|
+
// read before approving it. So is taking over hooks git did not route here:
|
|
19
|
+
// the bytes are journaled but the decision belongs to a human.
|
|
20
|
+
//
|
|
21
|
+
// Unsafe findings are never applied, under any flag combination. `--fix` without
|
|
22
|
+
// `--yes` previews: it names what it would apply and lists the rest.
|
|
23
|
+
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
26
|
+
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
27
|
+
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
28
|
+
import { resolveWorkspace } from '../workspace.mjs';
|
|
29
|
+
import { sharedWarning } from './shared.mjs';
|
|
30
|
+
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
31
|
+
import { manifestPath, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
32
|
+
import { planManifestRepair } from './sync.mjs';
|
|
33
|
+
import { provenanceDigest } from '../projection/project.mjs';
|
|
34
|
+
import { applyHooksInstall, plannedDiffs, planHooksInstall } from './hooks.mjs';
|
|
35
|
+
import { fileFor } from '../hooks/shim.mjs';
|
|
36
|
+
import { commitJournal, ignoreJournal, journalHygiene, journalIgnoreNext } from '../journal.mjs';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One config-health finding: what is wrong, how to fix it, and whether
|
|
40
|
+
* `doctor --fix` may apply that fix on its own.
|
|
41
|
+
*
|
|
42
|
+
* `apply`, when present, performs the repair into the caller's journal. Only
|
|
43
|
+
* findings with `safe: true` carry one; an unsafe finding is a report and a
|
|
44
|
+
* manual remedy, never a function waiting for a flag.
|
|
45
|
+
*/
|
|
46
|
+
// `withDiffs` previews the safe fixes as `--diff` pairs (plan 0003 R50). Read at
|
|
47
|
+
// finding creation, before any fix is applied, so the before side is true in
|
|
48
|
+
// preview and in apply alike. Only safe findings carry pairs: an unsafe finding
|
|
49
|
+
// is never applied, so there is no write to preview.
|
|
50
|
+
async function collectFindings(root, { result, ws, shared, withDiffs = false }) {
|
|
51
|
+
const findings = [];
|
|
52
|
+
if (result.error) {
|
|
53
|
+
// Nothing else can be asked: classification failed, so every check built on
|
|
54
|
+
// it would be a guess. The run fails upstream; this entry is for the JSON.
|
|
55
|
+
findings.push({
|
|
56
|
+
id: 'fingerprint',
|
|
57
|
+
severity: 'error',
|
|
58
|
+
detail: result.error,
|
|
59
|
+
remedy: null,
|
|
60
|
+
why: null,
|
|
61
|
+
safe: false,
|
|
62
|
+
});
|
|
63
|
+
return findings;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { manifest, exists, error: manifestError } = await readManifest(root);
|
|
67
|
+
if (manifestError !== null) {
|
|
68
|
+
// An unreadable manifest is a hand-edit, not a repair: re-rendering it
|
|
69
|
+
// would destroy whatever the other tool was saying.
|
|
70
|
+
findings.push({
|
|
71
|
+
id: 'manifest-unreadable',
|
|
72
|
+
severity: 'warning',
|
|
73
|
+
detail: `${MANIFEST_FILE}: ${manifestError}`,
|
|
74
|
+
remedy: `fix ${MANIFEST_FILE} by hand, or restore it from version control`,
|
|
75
|
+
why: 'only a human knows which bytes were intended',
|
|
76
|
+
safe: false,
|
|
77
|
+
});
|
|
78
|
+
} else if (!exists) {
|
|
79
|
+
// A repair restores intended state; an adoption chooses it. See the module
|
|
80
|
+
// header: `init` qualifies mechanically and is still listed, not applied.
|
|
81
|
+
findings.push({
|
|
82
|
+
id: 'manifest-absent',
|
|
83
|
+
severity: 'warning',
|
|
84
|
+
detail: `no ${MANIFEST_FILE}; this repository was never adopted`,
|
|
85
|
+
remedy: `run: ${CLI} init --root ${root} --yes`,
|
|
86
|
+
why: 'adoption chooses the rules; a fix only restores them',
|
|
87
|
+
safe: false,
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
const sha = await provenanceDigest();
|
|
91
|
+
const repair = await planManifestRepair(root, result.stack, sha); if (repair !== null) {
|
|
92
|
+
const staleBefore = withDiffs ? await readBefore(await manifestPath(root)) : null;
|
|
93
|
+
findings.push({
|
|
94
|
+
id: 'manifest-stale',
|
|
95
|
+
severity: 'warning',
|
|
96
|
+
detail: `generated field(s) ${repair.stale.join(', ')} do not match what this simt-dev writes`,
|
|
97
|
+
remedy: `run: ${CLI} sync --root ${root} --yes`,
|
|
98
|
+
why: null,
|
|
99
|
+
safe: true,
|
|
100
|
+
diffs: withDiffs ? [{ path: MANIFEST_FILE, before: staleBefore, after: repair.text }] : [],
|
|
101
|
+
apply: async (rt, journal) => {
|
|
102
|
+
await writeManifest(rt, repair.manifest, journal);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const planned = await planHooksInstall(root, { manifest, exists });
|
|
108
|
+
if (planned.takeover) {
|
|
109
|
+
const foreign = planned.state.foreign
|
|
110
|
+
.map((f) => `${f.event} runs ${f.path} (${f.bytes} bytes)`)
|
|
111
|
+
.join('; ');
|
|
112
|
+
findings.push({
|
|
113
|
+
id: 'hooks-takeover',
|
|
114
|
+
severity: 'warning',
|
|
115
|
+
detail:
|
|
116
|
+
foreign !== ''
|
|
117
|
+
? `git runs hooks this tool did not write: ${foreign}`
|
|
118
|
+
: `git routes hooks at ${planned.current}, not at the hooks directory`,
|
|
119
|
+
remedy: `inspect the existing hooks, then run: ${CLI} hooks install --root ${root} --yes`,
|
|
120
|
+
why: 'replacing live hooks needs a human who read them first',
|
|
121
|
+
safe: false,
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
const bits = [...planned.state.problems];
|
|
125
|
+
for (const h of planned.obsolete) {
|
|
126
|
+
bits.push(`${fileFor(h.event)} enforces nothing and is still routed; reinstall removes it`);
|
|
127
|
+
}
|
|
128
|
+
const evidenceMismatch =
|
|
129
|
+
planned.evidenceDeclared && planned.evidenceSource !== planned.evidenceExisting;
|
|
130
|
+
if (evidenceMismatch) bits.push('commit-msg claim check differs from this simt-dev; reinstall refreshes it');
|
|
131
|
+
if (bits.length > 0) {
|
|
132
|
+
const hooksPairs = withDiffs ? await plannedDiffs(root, planned) : [];
|
|
133
|
+
findings.push({
|
|
134
|
+
id: 'hooks-incomplete',
|
|
135
|
+
severity: 'warning',
|
|
136
|
+
detail: bits.join('; '),
|
|
137
|
+
remedy: `run: ${CLI} hooks install --root ${root} --yes`,
|
|
138
|
+
why: null,
|
|
139
|
+
safe: true,
|
|
140
|
+
diffs: hooksPairs,
|
|
141
|
+
apply: async (rt, journal) => {
|
|
142
|
+
await applyHooksInstall(rt, planned, journal);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hygiene = await journalHygiene(root);
|
|
149
|
+
if (!hygiene.ignored) {
|
|
150
|
+
const ignoreBefore = withDiffs ? await readBefore(join(root, '.gitignore')) : null;
|
|
151
|
+
const ignoreAfter = withDiffs ? journalIgnoreNext(ignoreBefore ?? '') : null;
|
|
152
|
+
findings.push({
|
|
153
|
+
id: 'journal-unignored',
|
|
154
|
+
severity: 'warning',
|
|
155
|
+
detail: `${hygiene.sets.length} journal set(s) on disk, but .gitignore does not ignore them; the next commit would share local undo state`,
|
|
156
|
+
remedy: 'ignore the journal directory (what --fix applies)',
|
|
157
|
+
why: null,
|
|
158
|
+
safe: true,
|
|
159
|
+
diffs: ignoreAfter === null ? [] : [{ path: '.gitignore', before: ignoreBefore, after: ignoreAfter }],
|
|
160
|
+
apply: async (rt, journal) => {
|
|
161
|
+
await ignoreJournal(rt, journal);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (shared.declared && shared.state !== 'current') {
|
|
168
|
+
// The copy lives outside the repository (a reader's home directory), which
|
|
169
|
+
// R49 deliberately leaves unjournaled: `undo` must never reach past the
|
|
170
|
+
// repo root, so a fix with no undo path is listed, never applied.
|
|
171
|
+
findings.push({
|
|
172
|
+
id: 'shared-copy',
|
|
173
|
+
severity: 'warning',
|
|
174
|
+
detail: shared.detail,
|
|
175
|
+
remedy: shared.state === 'absent' || shared.state === 'stale' ? `run: ${CLI} shared install --yes` : null,
|
|
176
|
+
why: 'the copy lives outside this repository, past what undo can restore',
|
|
177
|
+
safe: false,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (ws.error) {
|
|
182
|
+
findings.push({
|
|
183
|
+
id: 'workspace-config',
|
|
184
|
+
severity: 'error',
|
|
185
|
+
detail: `workspace config ${ws.detail}: ${ws.error}`,
|
|
186
|
+
remedy: 'fix the JSON, or remove the file',
|
|
187
|
+
why: 'only a human knows which workspace was intended',
|
|
188
|
+
safe: false,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return findings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function applySafeFixes(root, findings) {
|
|
195
|
+
const journal = [];
|
|
196
|
+
const fixed = [];
|
|
197
|
+
const failed = [];
|
|
198
|
+
for (const finding of findings) {
|
|
199
|
+
if (!finding.safe) continue;
|
|
200
|
+
try {
|
|
201
|
+
await finding.apply(root, journal);
|
|
202
|
+
finding.fixed = true;
|
|
203
|
+
fixed.push(finding.id);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
failed.push({ id: finding.id, error: error.message ?? String(error) });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (fixed.length > 0) await commitJournal(root, 'doctor --fix', journal);
|
|
209
|
+
return { applied: true, fixed, failed };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function findingLines(findings, { fixMode, applying }) {
|
|
213
|
+
return findings.flatMap((finding) => {
|
|
214
|
+
const head = `finding ${finding.id} ${finding.detail}`;
|
|
215
|
+
if (finding.fixed === true) return [head, ` fixed ${finding.remedy ?? 'applied'}`];
|
|
216
|
+
if (finding.remedy === null) return [head, ' manual: no command repairs this; read the detail above'];
|
|
217
|
+
if (!finding.safe) {
|
|
218
|
+
return [head, ` remedy: ${finding.remedy}`, ` manual: not applied by --fix (${finding.why})`];
|
|
219
|
+
}
|
|
220
|
+
if (!fixMode) return [head, ` remedy: ${finding.remedy}`, ' safe: apply with --fix --yes'];
|
|
221
|
+
if (!applying) return [head, ` remedy: ${finding.remedy}`, ' would fix; re-run with --yes to apply'];
|
|
222
|
+
return [head, ` remedy: ${finding.remedy}`];
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- core: the config health both the CLI and the MCP `doctor` tool answer from (Q15)
|
|
227
|
+
//
|
|
228
|
+
// The MCP tool returned the fingerprint alone: stack, facts and manifest, and none
|
|
229
|
+
// of the findings the command reports. The command below renders these facts for
|
|
230
|
+
// terminals; the tool returns them as JSON. The CLI keeps its field names; the
|
|
231
|
+
// tool adopts them.
|
|
232
|
+
//
|
|
233
|
+
// See the module header: classification first, because everything else is a guess without it.
|
|
234
|
+
export async function diagnose(root, args = {}) {
|
|
235
|
+
const result = await fingerprint(root);
|
|
236
|
+
// Which source answered the workspace question, and where it looked. A tool
|
|
237
|
+
// that silently fell back to the wrong directory would make every downstream
|
|
238
|
+
// answer confidently wrong, so the answer is reported rather than inferred
|
|
239
|
+
// (ADR-014).
|
|
240
|
+
const ws = await resolveWorkspace(args);
|
|
241
|
+
// The shared file this repository hands rules to (ADR-084). Asked here and nowhere else
|
|
242
|
+
// because it is the one thing about a repository that is true of the reader's machine
|
|
243
|
+
// rather than of the tree: `check` must not read `$HOME`, so `doctor` is the only command
|
|
244
|
+
// that can notice a region that dropped rules into a file nobody has. Reported, never
|
|
245
|
+
// blocking — the region is correct either way, and what is wrong is the reader's machine.
|
|
246
|
+
const shared = await sharedWarning(root, result.manifest ?? null, args);
|
|
247
|
+
|
|
248
|
+
const withDiffs = args.fix === true && args.diff === true;
|
|
249
|
+
const findings = await collectFindings(root, { result, ws, shared, withDiffs });
|
|
250
|
+
// Under `--json` the document is the whole report, so it has to carry both
|
|
251
|
+
// failures the text path reports AND the workspace answer the text prints.
|
|
252
|
+
const failure = result.error
|
|
253
|
+
? { error: `${root}: ${result.error}`, remedy: null }
|
|
254
|
+
: ws.error
|
|
255
|
+
? {
|
|
256
|
+
error: `workspace config ${ws.detail}: ${ws.error}`,
|
|
257
|
+
remedy: 'fix the JSON, or remove the file',
|
|
258
|
+
}
|
|
259
|
+
: null;
|
|
260
|
+
const code = failure ? EXIT.CONFIG : EXIT.OK;
|
|
261
|
+
return { result, ws, shared, findings, failure, code };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The findings as JSON-safe data: drops the `apply` closures only `--fix` uses. */
|
|
265
|
+
export function serialisedFindings(findings) {
|
|
266
|
+
return findings.map(({ id, severity, detail, remedy, why, safe, fixed }) => ({
|
|
267
|
+
id,
|
|
268
|
+
severity,
|
|
269
|
+
detail,
|
|
270
|
+
remedy,
|
|
271
|
+
// Null for safe findings: there is no human needed to explain.
|
|
272
|
+
why: why ?? null,
|
|
273
|
+
safe,
|
|
274
|
+
fixed: fixed === true,
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function runDoctor(positional, flags) {
|
|
279
|
+
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
280
|
+
const { result, ws, shared, findings, failure, code: diagnosisCode } = await diagnose(root, flags);
|
|
281
|
+
|
|
282
|
+
const fixMode = flags.fix === true;
|
|
283
|
+
const mode = writeMode(flags);
|
|
284
|
+
let fix = { requested: fixMode, applied: false, fixed: [], failed: [] };
|
|
285
|
+
if (fixMode && mode.apply) {
|
|
286
|
+
fix = { requested: true, ...(await applySafeFixes(root, findings)) };
|
|
287
|
+
}
|
|
288
|
+
const failedFix = fix.failed.length > 0;
|
|
289
|
+
|
|
290
|
+
// A scripted consumer has only this document to go on, so `ok` is derived from
|
|
291
|
+
// the exit code rather than written as a constant (S10). A failed fix is a
|
|
292
|
+
// failure of this run on top of the diagnosis.
|
|
293
|
+
const code = failedFix ? EXIT.FAIL : diagnosisCode;
|
|
294
|
+
|
|
295
|
+
// The findings travel in both modes: a script reads the array, a terminal
|
|
296
|
+
// reads the lines, and neither may learn about a different set of problems.
|
|
297
|
+
const serialised = serialisedFindings(findings);
|
|
298
|
+
|
|
299
|
+
if (isJson(flags)) {
|
|
300
|
+
emitJson({
|
|
301
|
+
ok: code === EXIT.OK,
|
|
302
|
+
command: 'doctor',
|
|
303
|
+
root,
|
|
304
|
+
...result,
|
|
305
|
+
// The workspace answer is a fact about which directory the tool would
|
|
306
|
+
// act on next (ADR-014). It is printed in the text report; leaving it
|
|
307
|
+
// out of the JSON meant a machine-readable caller could not see it at
|
|
308
|
+
// all, which is the one-question-two-answers defect again.
|
|
309
|
+
workspace: {
|
|
310
|
+
root: ws.root ?? null,
|
|
311
|
+
source: ws.source,
|
|
312
|
+
detail: ws.detail,
|
|
313
|
+
error: ws.error ?? null,
|
|
314
|
+
},
|
|
315
|
+
shared,
|
|
316
|
+
findings: serialised,
|
|
317
|
+
fix,
|
|
318
|
+
...diffField(fixMode && flags.diff === true ? findings.flatMap((f) => f.diffs ?? []) : [], flags),
|
|
319
|
+
...(failure ?? {}),
|
|
320
|
+
});
|
|
321
|
+
return code;
|
|
322
|
+
}
|
|
323
|
+
if (result.error) {
|
|
324
|
+
return fail(`${root}: ${result.error}`, EXIT.CONFIG);
|
|
325
|
+
}
|
|
326
|
+
emit(
|
|
327
|
+
[
|
|
328
|
+
`root ${root}`,
|
|
329
|
+
`stack ${result.stack.length > 0 ? result.stack.join(', ') : '(undetected)'}`,
|
|
330
|
+
`evidence ${result.evidence.length > 0 ? result.evidence.join(' ') : '(none)'}`,
|
|
331
|
+
// Facts are what a profile selects on when it wants to survive the
|
|
332
|
+
// framework being replaced, so they are reported with the path that proved
|
|
333
|
+
// each one rather than as a bare list (ADR-013).
|
|
334
|
+
`facts ${
|
|
335
|
+
result.facts.length > 0
|
|
336
|
+
? result.facts.map((f) => `${f}(${result.factEvidence[f] ?? '?'})`).join(' ')
|
|
337
|
+
: '(none)'
|
|
338
|
+
}`,
|
|
339
|
+
`manifest ${result.manifest ? (result.manifest.error ?? result.manifest.id ?? 'present') : '(absent)'}`,
|
|
340
|
+
`workspace ${ws.root ?? '(none)'} (${ws.source}: ${ws.detail})`,
|
|
341
|
+
// Printed only when this repository declares shared rules to a workspace copy: a line in
|
|
342
|
+
// every `doctor` is a line in every reader's terminal, and the reader who has
|
|
343
|
+
// nothing to install is not served by one (ADR-084).
|
|
344
|
+
...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
|
|
345
|
+
...findingLines(findings, { fixMode, applying: mode.apply }),
|
|
346
|
+
].join('\n'),
|
|
347
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
348
|
+
);
|
|
349
|
+
// After the report: the report says what, the diff shows it (R50). Only the
|
|
350
|
+
// fix plan is diffed, so without --fix there is nothing to show.
|
|
351
|
+
if (fixMode && flags.diff === true) emitDiffs(findings.flatMap((f) => f.diffs ?? []), flags);
|
|
352
|
+
if (failedFix) {
|
|
353
|
+
return fail(
|
|
354
|
+
`doctor --fix applied ${fix.fixed.length} remedie(s) and ${fix.failed.length} failed: ${fix.failed.map((f) => `${f.id} (${f.error})`).join('; ')}`,
|
|
355
|
+
EXIT.FAIL,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
// An unconfigured workspace is a fact, not a failure: a single repo with no
|
|
359
|
+
// workspace around it is a normal, supported case. A config file that EXISTS and
|
|
360
|
+
// does not parse is a real mistake, and that does fail - with the parse error,
|
|
361
|
+
// because naming the file without saying what is wrong with it sends the reader
|
|
362
|
+
// to the file and gives them nothing to look for.
|
|
363
|
+
if (ws.error) {
|
|
364
|
+
return fail(`workspace config ${ws.detail}: ${ws.error}`, EXIT.CONFIG, 'fix the JSON, or remove the file');
|
|
365
|
+
}
|
|
366
|
+
return EXIT.OK;
|
|
367
|
+
}
|
|
@@ -9,7 +9,14 @@ import { readFile } from 'node:fs/promises';
|
|
|
9
9
|
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
10
10
|
import { readManifest } from '../projection/manifest.mjs';
|
|
11
11
|
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
claimsVerification,
|
|
14
|
+
evidenceMode,
|
|
15
|
+
hasTestEvidence,
|
|
16
|
+
indexTreeId,
|
|
17
|
+
readLedger,
|
|
18
|
+
treeId,
|
|
19
|
+
} from '../ledger/index.mjs';
|
|
13
20
|
|
|
14
21
|
export async function evidenceCommand(args, flags) {
|
|
15
22
|
const root = flags.root ?? process.cwd();
|
|
@@ -72,14 +79,17 @@ async function checkClaim(root, file, flags) {
|
|
|
72
79
|
if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
|
|
73
80
|
return EXIT.OK;
|
|
74
81
|
}
|
|
82
|
+
// A commit records the index, not the worktree: `git add -p` can commit bytes
|
|
83
|
+
// the worktree tree never had (review 0003 Q18). Match on the staged tree, with
|
|
84
|
+
// the worktree tree as the fallback when git cannot name the index.
|
|
75
85
|
let tree;
|
|
76
86
|
try {
|
|
77
|
-
tree = await treeId(root);
|
|
87
|
+
tree = (await indexTreeId(root)) ?? (await treeId(root));
|
|
78
88
|
} catch (error) {
|
|
79
89
|
return fail(`could not name this tree (${error.message})`, EXIT.UNSUPPORTED, 'run this inside a git repository');
|
|
80
90
|
}
|
|
81
91
|
const { entries } = await readLedger(root);
|
|
82
|
-
const supported = entries
|
|
92
|
+
const supported = hasTestEvidence(entries, tree);
|
|
83
93
|
const payload = { ok: supported || mode === 'warn', command: 'evidence check', claim: true, supported, mode, tree };
|
|
84
94
|
if (supported) {
|
|
85
95
|
if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// simt explain <CODE> [--json]
|
|
2
|
+
//
|
|
3
|
+
// The last mile of the contract work is a reader: every status `simt check`
|
|
4
|
+
// can emit gets a plain-language entry — what it means, why the gate reports
|
|
5
|
+
// it, how to fix it, and a concrete trigger — either as text or as the same
|
|
6
|
+
// JSON envelope every command answers in. The lookup is case-insensitive and
|
|
7
|
+
// folds the `-`/`_` split, so DRIFT, Drift and drift are one question; an
|
|
8
|
+
// unknown code is a usage error that lists the known codes rather than a guess.
|
|
9
|
+
|
|
10
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
11
|
+
import { EXPLAIN_CODES } from '../explain.mjs';
|
|
12
|
+
|
|
13
|
+
export const EXPLAIN_ALIASES = { drifted: 'drift' };
|
|
14
|
+
|
|
15
|
+
function fold(raw) {
|
|
16
|
+
const folded = String(raw ?? '').toLowerCase().replace(/_/g, '-');
|
|
17
|
+
return EXPLAIN_ALIASES[folded] ?? folded;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function listExplainCodes() {
|
|
21
|
+
return Object.keys(EXPLAIN_CODES).sort();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function explainCommand(positional, flags) {
|
|
25
|
+
const raw = positional[0];
|
|
26
|
+
if (!raw) {
|
|
27
|
+
return fail(
|
|
28
|
+
'explain needs a code',
|
|
29
|
+
EXIT.USAGE,
|
|
30
|
+
`one of: ${listExplainCodes().join(', ')}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const code = fold(raw);
|
|
34
|
+
const row = EXPLAIN_CODES[code];
|
|
35
|
+
if (!row) {
|
|
36
|
+
return fail(
|
|
37
|
+
`unknown finding code \`${raw}\``,
|
|
38
|
+
EXIT.USAGE,
|
|
39
|
+
`one of: ${listExplainCodes().join(', ')}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (isJson(flags)) {
|
|
44
|
+
emitJson({ ok: true, command: 'explain', code, ...row });
|
|
45
|
+
return EXIT.OK;
|
|
46
|
+
}
|
|
47
|
+
emit(
|
|
48
|
+
[`code ${code}`, '', row.what, '', `Why: ${row.why}`, '', `Fix: ${row.fix}`, '', `Example: ${row.example}`].join('\n'),
|
|
49
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
50
|
+
);
|
|
51
|
+
return EXIT.OK;
|
|
52
|
+
}
|
package/src/commands/gate.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// this tool knows how to read is compacted; the raw log is kept.
|
|
7
7
|
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
9
10
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
11
12
|
import { promisify } from 'node:util';
|
|
12
13
|
|
|
13
14
|
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
@@ -15,7 +16,8 @@ import { CLI } from '../name.mjs';
|
|
|
15
16
|
import { detectTestRunner } from '../bench/mine.mjs';
|
|
16
17
|
import { compactOutput } from '../index/compact.mjs';
|
|
17
18
|
import { readImportEdges, selectTests } from '../index/gate.mjs';
|
|
18
|
-
import { packageOf } from '../index/store.mjs';
|
|
19
|
+
import { langOf, packageOf } from '../index/store.mjs';
|
|
20
|
+
import { localRel } from '../state.mjs';
|
|
19
21
|
|
|
20
22
|
const exec = promisify(execFile);
|
|
21
23
|
|
|
@@ -31,31 +33,81 @@ export async function gateCommand(args, flags) {
|
|
|
31
33
|
}
|
|
32
34
|
if (changed.length === 0) {
|
|
33
35
|
if (flags.changed === true) {
|
|
36
|
+
// No change: nothing is unmatched and no language is implicated, so the
|
|
37
|
+
// graph is vacuously resolved and the verdict is still said out loud.
|
|
34
38
|
if (isJson(flags)) {
|
|
35
|
-
emitJson(
|
|
39
|
+
emitJson(
|
|
40
|
+
{ ok: true, command: 'gate', root, selected: [], unmatched: [], graph: 'resolved', verdict: 'nothing-selected', code: 0 },
|
|
41
|
+
{ pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 },
|
|
42
|
+
);
|
|
36
43
|
} else {
|
|
37
|
-
emit('selected 0', { maxBytes: flags.maxBytes ?? 0 });
|
|
44
|
+
emit('selected 0\nverdict nothing-selected', { maxBytes: flags.maxBytes ?? 0 });
|
|
38
45
|
}
|
|
39
46
|
return EXIT.OK;
|
|
40
47
|
}
|
|
41
48
|
return fail(`nothing changed`, EXIT.USAGE, `pass a path, or ${CLI} gate --changed`);
|
|
42
49
|
}
|
|
50
|
+
// Q40: an explicit path that is neither tracked nor on disk names nothing the
|
|
51
|
+
// gate could select from. That is a usage error, not an empty selection: the
|
|
52
|
+
// old answer (`ok: true`, no tests, exit 0) was indistinguishable from "this
|
|
53
|
+
// change is safe" and trained the reader to trust it.
|
|
54
|
+
const unknown = changed.filter((p) => !listed.files.includes(p) && !existsSync(join(root, p)));
|
|
55
|
+
if (unknown.length > 0) {
|
|
56
|
+
return fail(
|
|
57
|
+
`no such file: ${unknown.join(', ')}`,
|
|
58
|
+
EXIT.USAGE,
|
|
59
|
+
`pass a path git tracks, or ${CLI} gate --changed`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
43
62
|
const files = new Set(listed.files);
|
|
44
63
|
for (const file of changed) files.add(file);
|
|
45
|
-
const
|
|
64
|
+
const edges = await readImportEdges(root, files);
|
|
65
|
+
const selected = selectTests(changed, edges);
|
|
66
|
+
// Q40: the selection explains itself. `unmatched` names explicit paths the
|
|
67
|
+
// tracked tree does not carry (untracked but present, so the run proceeds);
|
|
68
|
+
// `graph` is `unresolved` when a language has edges but none of them reaches
|
|
69
|
+
// a tracked file (the Kotlin estate: thousands of specs, zero resolutions);
|
|
70
|
+
// `verdict` says the empty set out loud instead of leaving `selected: []` to
|
|
71
|
+
// be read as either safe or blind. `ok` stays as is until W27's opt-in.
|
|
72
|
+
const unmatched = changed.filter((p) => !listed.files.includes(p));
|
|
73
|
+
const graph = graphState(edges, files);
|
|
46
74
|
if (selected.length === 0) {
|
|
47
|
-
const payload = { ok: true, command: 'gate', root, selected, code: 0 };
|
|
75
|
+
const payload = { ok: true, command: 'gate', root, selected, unmatched, graph, verdict: 'nothing-selected', code: 0 };
|
|
48
76
|
if (isJson(flags)) {
|
|
49
77
|
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
50
78
|
return EXIT.OK;
|
|
51
79
|
}
|
|
52
|
-
|
|
80
|
+
const lines = ['selected 0', 'verdict nothing-selected'];
|
|
81
|
+
if (unmatched.length > 0) lines.push(`unmatched ${unmatched.join(', ')}`);
|
|
82
|
+
if (graph === 'unresolved') lines.push('graph unresolved');
|
|
83
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
53
84
|
return EXIT.OK;
|
|
54
85
|
}
|
|
55
|
-
return runSelected(root, flags, selected);
|
|
86
|
+
return runSelected(root, flags, selected, { unmatched, graph });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `resolved` unless some language present in the edges has total edges but
|
|
91
|
+
* zero reaching a tracked file. A language with no edges at all is not
|
|
92
|
+
* evidence of anything - an unimported file needs no resolution.
|
|
93
|
+
*/
|
|
94
|
+
function graphState(edges, files) {
|
|
95
|
+
const byLang = new Map();
|
|
96
|
+
for (const edge of edges) {
|
|
97
|
+
const lang = langOf(edge.src);
|
|
98
|
+
if (!lang) continue;
|
|
99
|
+
const stat = byLang.get(lang) ?? { total: 0, resolved: 0 };
|
|
100
|
+
stat.total += 1;
|
|
101
|
+
if (files.has(edge.dst)) stat.resolved += 1;
|
|
102
|
+
byLang.set(lang, stat);
|
|
103
|
+
}
|
|
104
|
+
for (const stat of byLang.values()) {
|
|
105
|
+
if (stat.total > 0 && stat.resolved === 0) return 'unresolved';
|
|
106
|
+
}
|
|
107
|
+
return 'resolved';
|
|
56
108
|
}
|
|
57
109
|
|
|
58
|
-
async function runSelected(root, flags, selected) {
|
|
110
|
+
async function runSelected(root, flags, selected, { unmatched = [], graph = 'resolved' } = {}) {
|
|
59
111
|
const cache = new Map();
|
|
60
112
|
const groups = new Map();
|
|
61
113
|
for (const file of selected) {
|
|
@@ -106,7 +158,18 @@ async function runSelected(root, flags, selected) {
|
|
|
106
158
|
}
|
|
107
159
|
const raw = await writeRaw(root, rawParts.join('\n'));
|
|
108
160
|
if (raw) text.push('', `raw ${raw}`);
|
|
109
|
-
const payload = {
|
|
161
|
+
const payload = {
|
|
162
|
+
ok: code === 0,
|
|
163
|
+
command: 'gate',
|
|
164
|
+
root,
|
|
165
|
+
selected,
|
|
166
|
+
unmatched,
|
|
167
|
+
graph,
|
|
168
|
+
verdict: 'selected',
|
|
169
|
+
code,
|
|
170
|
+
raw,
|
|
171
|
+
runs,
|
|
172
|
+
};
|
|
110
173
|
if (isJson(flags)) {
|
|
111
174
|
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
112
175
|
return code === 0 ? EXIT.OK : EXIT.FAIL;
|
|
@@ -116,12 +179,13 @@ async function runSelected(root, flags, selected) {
|
|
|
116
179
|
}
|
|
117
180
|
|
|
118
181
|
async function writeRaw(root, body) {
|
|
182
|
+
// Raw gate output is machine-local state (review 0003 Q19).
|
|
119
183
|
try {
|
|
120
|
-
const
|
|
184
|
+
const rel = localRel('gate', `gate-${Date.now()}.log`);
|
|
185
|
+
const dir = join(root, dirname(rel));
|
|
121
186
|
await mkdir(dir, { recursive: true });
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return `.simt/gate/${name}`;
|
|
187
|
+
await writeFile(join(root, rel), body);
|
|
188
|
+
return rel;
|
|
125
189
|
} catch {
|
|
126
190
|
return '';
|
|
127
191
|
}
|
|
@@ -129,8 +193,10 @@ async function writeRaw(root, body) {
|
|
|
129
193
|
|
|
130
194
|
async function tracked(root) {
|
|
131
195
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
196
|
+
// `-z`: without it git C-quotes a non-ASCII path (`src/\303\274n...`) and
|
|
197
|
+
// the reader's real filename never matches it (review 0003 Q39).
|
|
198
|
+
const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
|
|
199
|
+
return { files: stdout.split('\0').filter(Boolean) };
|
|
134
200
|
} catch (error) {
|
|
135
201
|
return {
|
|
136
202
|
error: fail(
|
|
@@ -144,9 +210,9 @@ async function tracked(root) {
|
|
|
144
210
|
|
|
145
211
|
async function changedFiles(root) {
|
|
146
212
|
try {
|
|
147
|
-
const { stdout } = await exec('git', ['diff', '--name-only', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
|
|
148
|
-
const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, maxBuffer: 8 << 20 });
|
|
149
|
-
return { files: [...new Set([...stdout.split('\
|
|
213
|
+
const { stdout } = await exec('git', ['diff', '--name-only', '-z', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
|
|
214
|
+
const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 8 << 20 });
|
|
215
|
+
return { files: [...new Set([...stdout.split('\0'), ...untracked.stdout.split('\0')].filter(Boolean))].sort() };
|
|
150
216
|
} catch (error) {
|
|
151
217
|
return {
|
|
152
218
|
error: fail(
|
package/src/commands/graph.mjs
CHANGED
|
@@ -31,16 +31,29 @@ export async function lintCommand(positional, flags) {
|
|
|
31
31
|
// client accept these files", and the second question needs no registry at all. A
|
|
32
32
|
// reader with no registry can still lint a skill directory, so the two are additive
|
|
33
33
|
// rather than one gating the other.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
//
|
|
35
|
+
// The pass runs only when asked for (`--spec`) and only against a verified
|
|
36
|
+
// registry: without it the default lint reported 37 warnings for a pass the
|
|
37
|
+
// payload claimed never ran (`spec: null`), and `ok` stayed true over errors
|
|
38
|
+
// the spec pass had just found (review 0003 Q35).
|
|
39
|
+
let specReport = { errors: [], warnings: [], checked: 0 };
|
|
40
|
+
if (spec !== null) {
|
|
41
|
+
if (registry && verified && workspace?.root) {
|
|
42
|
+
specReport = await lintSpec(registry, workspace.root);
|
|
43
|
+
} else {
|
|
44
|
+
warnings.push(
|
|
45
|
+
'spec pass skipped: it needs --spec against a registry verified on disk (see `verified` above)',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
errors.push(...specReport.errors);
|
|
38
50
|
warnings.push(...specReport.warnings);
|
|
51
|
+
const finalOk = ok && errors.length === 0;
|
|
39
52
|
|
|
40
53
|
if (isJson(flags)) {
|
|
41
54
|
emitJson(
|
|
42
55
|
{
|
|
43
|
-
ok,
|
|
56
|
+
ok: finalOk,
|
|
44
57
|
registry: path,
|
|
45
58
|
capabilities: registry?.capabilities?.length ?? 0,
|
|
46
59
|
skills: registry?.skills?.length ?? 0,
|
|
@@ -60,7 +73,7 @@ export async function lintCommand(positional, flags) {
|
|
|
60
73
|
},
|
|
61
74
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
62
75
|
);
|
|
63
|
-
return
|
|
76
|
+
return finalOk ? EXIT.OK : EXIT.REGISTRY;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
if (!registry) {
|