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
package/src/memory/lessons.mjs
CHANGED
|
@@ -13,20 +13,64 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { execFile } from 'node:child_process';
|
|
16
|
-
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { mkdir, open, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import { promisify } from 'node:util';
|
|
19
19
|
|
|
20
20
|
import { postDeliveryJson } from '../agenthooks/render.mjs';
|
|
21
21
|
import { lessonPaths } from '../index/govern.mjs';
|
|
22
|
+
import { recordCreatedFile } from '../journal.mjs';
|
|
23
|
+
import { redactText } from '../secrets.mjs';
|
|
24
|
+
import { localRel, readStateFile } from '../state.mjs';
|
|
22
25
|
import { splitFrontmatter } from './decide.mjs';
|
|
23
26
|
|
|
24
27
|
const exec = promisify(execFile);
|
|
25
28
|
|
|
26
|
-
export const LESSONS_FILE = '
|
|
29
|
+
export const LESSONS_FILE = localRel('lessons.jsonl');
|
|
30
|
+
const LESSONS_FILE_LEGACY = '.simt/lessons.jsonl';
|
|
31
|
+
const LEDGER_REL = localRel('ledger.jsonl');
|
|
32
|
+
const LEDGER_REL_LEGACY = '.simt/ledger.jsonl';
|
|
27
33
|
export const LESSONS_DIR = 'docs/lessons';
|
|
28
34
|
export const LESSON_KEYS = new Set(['simt-trigger', 'simt-evidence', 'simt-expires']);
|
|
29
|
-
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A stored `fixDiff` is evidence, not an archive: the diff that proved the fix,
|
|
38
|
+
* capped so one generated-file revert cannot bloat every read of the ledger.
|
|
39
|
+
* Truncation keeps the head (the hunk headers that name the files) and says so.
|
|
40
|
+
*/
|
|
41
|
+
const FIX_DIFF_BUDGET = 20000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The default expiry is relative to the promotion, not a date in the source:
|
|
45
|
+
* a fixed date rots (every lesson written today expires on someone else's
|
|
46
|
+
* schedule) and a missing one lives forever because nothing reads the field.
|
|
47
|
+
* Half a year is long enough to recur and short enough to re-earn.
|
|
48
|
+
*/
|
|
49
|
+
const EXPIRY_DAYS = 180;
|
|
50
|
+
|
|
51
|
+
export function defaultExpires(from = new Date()) {
|
|
52
|
+
const at = new Date(from);
|
|
53
|
+
at.setUTCDate(at.getUTCDate() + EXPIRY_DAYS);
|
|
54
|
+
return at.toISOString().slice(0, 10);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a `simt-expires` value has passed. Unparseable values are NOT expired:
|
|
59
|
+
* a malformed date is a data error the reader should see, and hiding the lesson
|
|
60
|
+
* would turn it into a silent one (review 0003 Q36).
|
|
61
|
+
*
|
|
62
|
+
* @param {string|null|undefined} expires
|
|
63
|
+
* @param {Date} [now]
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
export function isExpired(expires, now = new Date()) {
|
|
67
|
+
if (expires === null || expires === undefined || String(expires).trim() === '') return false;
|
|
68
|
+
const at = new Date(String(expires).trim());
|
|
69
|
+
if (Number.isNaN(at.getTime())) return false;
|
|
70
|
+
const today = new Date(now);
|
|
71
|
+
today.setUTCHours(0, 0, 0, 0);
|
|
72
|
+
return at.getTime() < today.getTime();
|
|
73
|
+
}
|
|
30
74
|
|
|
31
75
|
const STOP_WORDS = new Set([
|
|
32
76
|
'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'were',
|
|
@@ -143,13 +187,14 @@ function slugify(text) {
|
|
|
143
187
|
* @returns {Promise<{candidates: Array<object>, errors: string[]}>}
|
|
144
188
|
*/
|
|
145
189
|
export async function readLessonCandidates(root) {
|
|
190
|
+
// The pre-move path is a fallback for one release (review 0003 Q19).
|
|
146
191
|
let text;
|
|
147
192
|
try {
|
|
148
|
-
text = await
|
|
193
|
+
({ text } = await readStateFile(root, LESSONS_FILE, [LESSONS_FILE_LEGACY]));
|
|
149
194
|
} catch (error) {
|
|
150
|
-
if (error.code === 'ENOENT') return { candidates: [], errors: [] };
|
|
151
195
|
throw error;
|
|
152
196
|
}
|
|
197
|
+
if (text === null) return { candidates: [], errors: [] };
|
|
153
198
|
const candidates = [];
|
|
154
199
|
const errors = [];
|
|
155
200
|
for (const [i, raw] of text.split('\n').entries()) {
|
|
@@ -175,15 +220,69 @@ async function writeLessonCandidates(root, candidates) {
|
|
|
175
220
|
await writeFile(full, body ? `${body}\n` : '', 'utf8');
|
|
176
221
|
}
|
|
177
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Append one record with `O_APPEND`: the ledger is written by concurrent
|
|
225
|
+
* processes (gates, hooks, the CLI), and a read-modify-write loses one of two
|
|
226
|
+
* simultaneous appends. The position is the kernel's, not a length read earlier.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} root
|
|
229
|
+
* @param {object} record
|
|
230
|
+
*/
|
|
231
|
+
async function appendCandidateLine(root, record) {
|
|
232
|
+
const full = join(root, LESSONS_FILE);
|
|
233
|
+
await mkdir(dirname(full), { recursive: true });
|
|
234
|
+
const handle = await open(full, 'a');
|
|
235
|
+
try {
|
|
236
|
+
await handle.appendFile(`${JSON.stringify(record)}\n`, 'utf8');
|
|
237
|
+
} finally {
|
|
238
|
+
await handle.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The candidate id is a content hash, not a counter. Ids were `c-${length+1}`:
|
|
244
|
+
* two processes appending concurrently computed the same next index, and a
|
|
245
|
+
* hand-edited or truncated file renumbered everything after it. The hash covers
|
|
246
|
+
* the fields that make a candidate distinct, so the same candidate always maps
|
|
247
|
+
* to the same id and two different ones never share it (review 0003 Q36).
|
|
248
|
+
*
|
|
249
|
+
* @param {{kind: string, signature: string, evidence: string[], cmd: string|null}} parts
|
|
250
|
+
* @returns {string}
|
|
251
|
+
*/
|
|
252
|
+
export function lessonIdFor({ kind, signature, evidence = [], cmd = null }) {
|
|
253
|
+
const digest = createHash('sha256')
|
|
254
|
+
.update([kind, signature, ...evidence, cmd ?? ''].join('\n'), 'utf8')
|
|
255
|
+
.digest('hex');
|
|
256
|
+
return `l-${digest.slice(0, 12)}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Truncate an unbounded diff to the evidence budget, keeping the head: the hunk
|
|
261
|
+
* headers that name the files are the part a reader acts on.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} diff
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
function capFixDiff(diff) {
|
|
267
|
+
const text = String(diff ?? '');
|
|
268
|
+
if (text.length <= FIX_DIFF_BUDGET) return text;
|
|
269
|
+
return `${text.slice(0, FIX_DIFF_BUDGET)}\n… (truncated to ${FIX_DIFF_BUDGET} chars)`;
|
|
270
|
+
}
|
|
271
|
+
|
|
178
272
|
function sameEvidence(a = [], b = []) {
|
|
179
273
|
if (a.length !== b.length) return false;
|
|
180
274
|
return a.every((v, i) => v === b[i]);
|
|
181
275
|
}
|
|
182
276
|
|
|
183
277
|
/**
|
|
184
|
-
* Append one deterministic candidate to
|
|
278
|
+
* Append one deterministic candidate to the lessons ledger, deduplicating by
|
|
185
279
|
* `(kind, signature, evidence)`.
|
|
186
280
|
*
|
|
281
|
+
* The stored text is redacted before anything else: candidates keep the raw
|
|
282
|
+
* commands that failed, and a command can carry a token. Redaction runs before
|
|
283
|
+
* the signature is hashed so the same failure redacts to the same signature
|
|
284
|
+
* (review 0003 Q36).
|
|
285
|
+
*
|
|
187
286
|
* @param {string} root
|
|
188
287
|
* @param {object} candidate
|
|
189
288
|
* @returns {Promise<object>}
|
|
@@ -191,15 +290,22 @@ function sameEvidence(a = [], b = []) {
|
|
|
191
290
|
export async function appendLessonCandidate(root, candidate) {
|
|
192
291
|
const { candidates } = await readLessonCandidates(root);
|
|
193
292
|
const kind = String(candidate.kind ?? 'fail-then-fix');
|
|
194
|
-
|
|
293
|
+
// Redact first: the signature must be a function of the stored bytes, not of
|
|
294
|
+
// a secret nobody can reproduce (and must never be stored).
|
|
295
|
+
const errorLine = redactText(
|
|
296
|
+
normaliseErrorLine(candidate.errorLine ?? firstErrorLine(candidate.output ?? candidate.summary ?? '')),
|
|
297
|
+
);
|
|
195
298
|
const signature = candidate.signature ?? errorSignature(errorLine);
|
|
196
|
-
const evidence = Array.isArray(candidate.evidence)
|
|
299
|
+
const evidence = Array.isArray(candidate.evidence)
|
|
300
|
+
? candidate.evidence.map((e) => redactText(String(e)).trim()).filter(Boolean)
|
|
301
|
+
: [];
|
|
302
|
+
const cmd = candidate.cmd === null || candidate.cmd === undefined ? null : redactText(String(candidate.cmd));
|
|
197
303
|
|
|
198
304
|
const existing = candidates.find(
|
|
199
305
|
(c) =>
|
|
200
306
|
c.kind === kind &&
|
|
201
307
|
c.signature === signature &&
|
|
202
|
-
(sameEvidence(c.evidence ?? [], evidence) || (c.cmd &&
|
|
308
|
+
(sameEvidence(c.evidence ?? [], evidence) || (c.cmd && cmd && c.cmd === cmd && c.evidence?.[0] === evidence[0])),
|
|
203
309
|
);
|
|
204
310
|
if (existing) return existing;
|
|
205
311
|
|
|
@@ -208,28 +314,26 @@ export async function appendLessonCandidate(root, candidate) {
|
|
|
208
314
|
: [];
|
|
209
315
|
const keywords = Array.isArray(candidate.keywords) && candidate.keywords.length > 0
|
|
210
316
|
? [...new Set(candidate.keywords.map(String).filter(Boolean))]
|
|
211
|
-
: extractKeywords(`${errorLine} ${
|
|
317
|
+
: extractKeywords(`${errorLine} ${cmd ?? ''}`, paths);
|
|
212
318
|
|
|
213
|
-
const nextIndex = candidates.length + 1;
|
|
214
319
|
const record = {
|
|
215
320
|
v: 1,
|
|
216
|
-
id: candidate.id ??
|
|
321
|
+
id: candidate.id ?? lessonIdFor({ kind, signature, evidence, cmd }),
|
|
217
322
|
ts: candidate.ts ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
218
323
|
kind,
|
|
219
324
|
signature,
|
|
220
325
|
errorLine,
|
|
221
|
-
summary: String(candidate.summary ?? (errorLine || `${kind} candidate`)).trim(),
|
|
222
|
-
cmd
|
|
326
|
+
summary: redactText(String(candidate.summary ?? (errorLine || `${kind} candidate`))).trim(),
|
|
327
|
+
cmd,
|
|
223
328
|
paths,
|
|
224
329
|
keywords,
|
|
225
330
|
evidence,
|
|
226
|
-
fixDiff:
|
|
331
|
+
fixDiff: capFixDiff(redactText(candidate.fixDiff ?? '')),
|
|
227
332
|
status: candidate.status ?? 'candidate',
|
|
228
333
|
promotedTo: candidate.promotedTo ?? null,
|
|
229
334
|
};
|
|
230
335
|
|
|
231
|
-
|
|
232
|
-
await writeLessonCandidates(root, candidates);
|
|
336
|
+
await appendCandidateLine(root, record);
|
|
233
337
|
return record;
|
|
234
338
|
}
|
|
235
339
|
|
|
@@ -306,8 +410,8 @@ export async function captureFromLedger(root, entries = null) {
|
|
|
306
410
|
let list = entries;
|
|
307
411
|
if (!Array.isArray(list)) {
|
|
308
412
|
try {
|
|
309
|
-
const
|
|
310
|
-
list =
|
|
413
|
+
const { text } = await readStateFile(root, LEDGER_REL, [LEDGER_REL_LEGACY]);
|
|
414
|
+
list = (text ?? '')
|
|
311
415
|
.split('\n')
|
|
312
416
|
.map((s) => s.trim())
|
|
313
417
|
.filter(Boolean)
|
|
@@ -481,12 +585,13 @@ export async function recordHookDenial(root, denial) {
|
|
|
481
585
|
* @returns {string}
|
|
482
586
|
*/
|
|
483
587
|
export function renderLessonMarkdown(candidate, options = {}) {
|
|
588
|
+
// No `**` fallback: a trigger with no paths is a promotion the caller
|
|
589
|
+
// refused, not a lesson about everything. `promoteLesson` rejects those;
|
|
590
|
+
// this renders exactly the paths it was given (review 0003 Q36).
|
|
484
591
|
const paths =
|
|
485
592
|
Array.isArray(options.paths) && options.paths.length > 0
|
|
486
593
|
? options.paths
|
|
487
|
-
: Array.isArray(candidate.paths)
|
|
488
|
-
? candidate.paths
|
|
489
|
-
: ['**'];
|
|
594
|
+
: (Array.isArray(candidate.paths) ? candidate.paths : []);
|
|
490
595
|
const keywords =
|
|
491
596
|
Array.isArray(options.keywords) && options.keywords.length > 0
|
|
492
597
|
? options.keywords
|
|
@@ -494,7 +599,7 @@ export function renderLessonMarkdown(candidate, options = {}) {
|
|
|
494
599
|
? candidate.keywords
|
|
495
600
|
: extractKeywords(candidate.errorLine ?? candidate.summary ?? '', paths);
|
|
496
601
|
const evidence = Array.isArray(candidate.evidence) ? candidate.evidence : [];
|
|
497
|
-
const expires = options.expires ??
|
|
602
|
+
const expires = options.expires ?? defaultExpires();
|
|
498
603
|
const title = String(options.title ?? candidate.summary ?? candidate.errorLine ?? 'Lesson').trim();
|
|
499
604
|
|
|
500
605
|
const pathsLiteral = paths.map((p) => JSON.stringify(String(p))).join(', ');
|
|
@@ -528,7 +633,7 @@ export function renderLessonMarkdown(candidate, options = {}) {
|
|
|
528
633
|
* @param {string} text
|
|
529
634
|
* @returns {object}
|
|
530
635
|
*/
|
|
531
|
-
export function parseLessonFile(id, text) {
|
|
636
|
+
export function parseLessonFile(id, text, now = new Date()) {
|
|
532
637
|
const { front: rawFront, body: rawBody } = splitFrontmatter(text);
|
|
533
638
|
const front = rawFront ?? '';
|
|
534
639
|
const body = rawBody.trim();
|
|
@@ -546,6 +651,7 @@ export function parseLessonFile(id, text) {
|
|
|
546
651
|
: [];
|
|
547
652
|
const expMatch = /^simt-expires:\s*(\S+)/m.exec(front);
|
|
548
653
|
const expires = expMatch ? expMatch[1].trim() : null;
|
|
654
|
+
const expired = isExpired(expires, now);
|
|
549
655
|
|
|
550
656
|
const titleMatch = /^#\s+(.+)$/m.exec(body);
|
|
551
657
|
const title = titleMatch ? titleMatch[1].trim() : id;
|
|
@@ -566,16 +672,23 @@ export function parseLessonFile(id, text) {
|
|
|
566
672
|
keywords,
|
|
567
673
|
evidence,
|
|
568
674
|
expires,
|
|
675
|
+
expired,
|
|
569
676
|
};
|
|
570
677
|
}
|
|
571
678
|
|
|
572
679
|
/**
|
|
573
|
-
* List
|
|
680
|
+
* List the promoted lessons in `docs/lessons/*.md` that are still in force.
|
|
681
|
+
*
|
|
682
|
+
* `simt-expires` is read here, once, so every consumer — recall, `why`, the
|
|
683
|
+
* hook, the bench, `lessons list` — agrees on what has expired. Nothing was
|
|
684
|
+
* reading the field, so expired lessons kept firing indefinitely (review 0003
|
|
685
|
+
* Q36). Pass `includeExpired` to see the retired ones with `expired: true`.
|
|
574
686
|
*
|
|
575
687
|
* @param {string} root
|
|
688
|
+
* @param {{includeExpired?: boolean, now?: Date}} [options]
|
|
576
689
|
* @returns {Promise<Array<object>>}
|
|
577
690
|
*/
|
|
578
|
-
export async function listPromotedLessons(root) {
|
|
691
|
+
export async function listPromotedLessons(root, { includeExpired = false, now = new Date() } = {}) {
|
|
579
692
|
const dir = join(root, LESSONS_DIR);
|
|
580
693
|
let names = [];
|
|
581
694
|
try {
|
|
@@ -587,7 +700,8 @@ export async function listPromotedLessons(root) {
|
|
|
587
700
|
for (const name of names) {
|
|
588
701
|
const text = await readFile(join(dir, name), 'utf8');
|
|
589
702
|
const id = name.replace(/\.md$/, '');
|
|
590
|
-
|
|
703
|
+
const lesson = parseLessonFile(id, text, now);
|
|
704
|
+
if (!lesson.expired || includeExpired) lessons.push(lesson);
|
|
591
705
|
}
|
|
592
706
|
return lessons;
|
|
593
707
|
}
|
|
@@ -612,17 +726,20 @@ async function nextLessonNumber(root) {
|
|
|
612
726
|
}
|
|
613
727
|
|
|
614
728
|
/**
|
|
615
|
-
* Promote a lesson candidate from
|
|
729
|
+
* Promote a lesson candidate from the lessons ledger to `docs/lessons/NNNN-<slug>.md`.
|
|
730
|
+
*
|
|
731
|
+
* Capture is not promotion's business: `promoteLesson` used to scan the ledger
|
|
732
|
+
* first, so a preview (`review` without `--yes`) wrote to the store it was
|
|
733
|
+
* only reading. Capture is an explicit `lessons capture` now (review 0003 Q36).
|
|
616
734
|
*
|
|
617
735
|
* @param {string} root
|
|
618
|
-
* @param {{id?: string, apply?: boolean, paths?: string[], expires?: string, title?: string}} [options]
|
|
736
|
+
* @param {{id?: string, apply?: boolean, paths?: string[], expires?: string, title?: string, journal?: object[]}} [options]
|
|
619
737
|
* @returns {Promise<{ok: boolean, error?: string, candidate?: object, path?: string, markdown?: string, written?: boolean}>}
|
|
620
738
|
*/
|
|
621
739
|
export async function promoteLesson(root, options = {}) {
|
|
622
|
-
await captureFromLedger(root);
|
|
623
740
|
const { candidates } = await readLessonCandidates(root);
|
|
624
741
|
if (candidates.length === 0) {
|
|
625
|
-
return { ok: false, error:
|
|
742
|
+
return { ok: false, error: `no lesson candidates in ${LESSONS_FILE}; capture some with: lessons capture` };
|
|
626
743
|
}
|
|
627
744
|
|
|
628
745
|
let target = null;
|
|
@@ -633,14 +750,31 @@ export async function promoteLesson(root, options = {}) {
|
|
|
633
750
|
if (!target) {
|
|
634
751
|
return { ok: false, error: `no lesson candidate matching "${options.id}"` };
|
|
635
752
|
}
|
|
753
|
+
if (target.status === 'promoted') {
|
|
754
|
+
return { ok: false, error: `lesson candidate ${target.id} is already promoted to ${target.promotedTo}` };
|
|
755
|
+
}
|
|
636
756
|
} else {
|
|
637
|
-
|
|
757
|
+
// A promoted candidate is done: falling back to it re-promotes the same
|
|
758
|
+
// lesson under a new number, which is how one candidate became 0001/0002/0003.
|
|
759
|
+
target = candidates.find((c) => c.status !== 'promoted') ?? null;
|
|
760
|
+
if (!target) {
|
|
761
|
+
return { ok: false, error: 'nothing to promote: every candidate is already promoted' };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// No `**` default: a trigger that watches every path fires on every failure,
|
|
766
|
+
// which is the same as firing on none. A pathless candidate needs `--paths`.
|
|
767
|
+
const paths = Array.isArray(options.paths) && options.paths.length > 0
|
|
768
|
+
? options.paths
|
|
769
|
+
: (Array.isArray(target.paths) ? target.paths : []);
|
|
770
|
+
if (paths.length === 0) {
|
|
771
|
+
return { ok: false, error: `lesson candidate ${target.id} names no paths; pass --paths to say what it governs` };
|
|
638
772
|
}
|
|
639
773
|
|
|
640
774
|
const num = await nextLessonNumber(root);
|
|
641
775
|
const slug = slugify(options.title ?? target.errorLine ?? target.summary ?? target.kind);
|
|
642
776
|
const relPath = `${LESSONS_DIR}/${num}-${slug}.md`;
|
|
643
|
-
const markdown = renderLessonMarkdown(target, options);
|
|
777
|
+
const markdown = renderLessonMarkdown(target, { ...options, paths, expires: options.expires ?? defaultExpires() });
|
|
644
778
|
|
|
645
779
|
// The `--diff` before-side, read before anything is written so the diff is true
|
|
646
780
|
// in preview and in apply alike (R50). Always a creation in practice — the
|
|
@@ -657,6 +791,7 @@ export async function promoteLesson(root, options = {}) {
|
|
|
657
791
|
const fullPath = join(root, relPath);
|
|
658
792
|
await mkdir(dirname(fullPath), { recursive: true });
|
|
659
793
|
await writeFile(fullPath, markdown, 'utf8');
|
|
794
|
+
if (Array.isArray(options.journal)) recordCreatedFile(root, options.journal, join(root, relPath), markdown);
|
|
660
795
|
target.status = 'promoted';
|
|
661
796
|
target.promotedTo = relPath;
|
|
662
797
|
await writeLessonCandidates(root, candidates);
|
package/src/name.mjs
CHANGED
|
@@ -32,6 +32,15 @@ export const MANIFEST_FILE = 'simt.json';
|
|
|
32
32
|
/** What the manifest file was called, and still is in every adopted repo. */
|
|
33
33
|
export const MANIFEST_LEGACY = 'simt-dev.json';
|
|
34
34
|
|
|
35
|
+
/** The projection lock: the input and output digests a frozen check compares, and the
|
|
36
|
+
* locked `variants` winners `simt bench variants` records (ADR-086). */
|
|
37
|
+
export const LOCK_FILE = 'simt.lock.json';
|
|
38
|
+
|
|
39
|
+
/** What the lock was called. Read, not written: an adopted repo's lock under the old
|
|
40
|
+
* name may hold variant winners nothing can regenerate, so it is read until the next
|
|
41
|
+
* write migrates it (ADR-110). */
|
|
42
|
+
export const LOCK_LEGACY = 'simt-dev.lock.json';
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* The namespace the generated region writes into a repository's files: the begin and end
|
|
37
46
|
* markers, and the provenance header inside them. It follows the command, like the
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The runtime floor, in one place both entry points read (review 0003 Q54).
|
|
2
|
+
//
|
|
3
|
+
// `package.json` declares `engines.node` and `pyproject.toml` declares a python
|
|
4
|
+
// version; both are advisory to the installer, so an older runtime reached the CLI
|
|
5
|
+
// and failed somewhere inside it — a syntax error, a missing API — instead of the one
|
|
6
|
+
// sentence that says what to install. This module is deliberately tiny and has no
|
|
7
|
+
// imports: the bootstrap that calls it must not load the tool that cannot run.
|
|
8
|
+
|
|
9
|
+
/** The oldest Node this package runs on. Mirrors `package.json` `engines.node`. */
|
|
10
|
+
export const MIN_NODE = '22.20.0';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether `current` is at least `minimum`, compared component by component.
|
|
14
|
+
*
|
|
15
|
+
* A string compare is wrong ("9.0.0" > "22.20.0"), and `process.versions.node` may
|
|
16
|
+
* carry a pre-release suffix that `Number()` would turn into NaN, so the numeric
|
|
17
|
+
* prefix of each component is what counts.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} current a version such as `22.20.0` or `23.1.0-rc.1`
|
|
20
|
+
* @param {string} [minimum]
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function satisfiesMinimum(current, minimum = MIN_NODE) {
|
|
24
|
+
const parse = (value) =>
|
|
25
|
+
String(value)
|
|
26
|
+
.split('.')
|
|
27
|
+
.map((part) => Number.parseInt(part, 10))
|
|
28
|
+
.map((part) => (Number.isFinite(part) ? part : 0));
|
|
29
|
+
const have = parse(current);
|
|
30
|
+
const want = parse(minimum);
|
|
31
|
+
for (let i = 0; i < Math.max(have.length, want.length); i += 1) {
|
|
32
|
+
const a = have[i] ?? 0;
|
|
33
|
+
const b = want[i] ?? 0;
|
|
34
|
+
if (a !== b) return a > b;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
package/src/output/index.mjs
CHANGED
|
@@ -92,7 +92,27 @@ export function writeMode(flags) {
|
|
|
92
92
|
* Truncation is always announced with an explicit line, never a silent ellipsis:
|
|
93
93
|
* an agent that cannot tell a truncated answer from a complete one will guess.
|
|
94
94
|
*/
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Answer one command's result on the surface the caller asked for.
|
|
97
|
+
*
|
|
98
|
+
* Every command that has both a document and a text report was choosing between them by
|
|
99
|
+
* hand, in the same five lines, in more than a dozen files - and a command that chose
|
|
100
|
+
* slightly differently printed a report where a caller had asked for JSON, which is how a
|
|
101
|
+
* script gets prose (review 0003 Q63).
|
|
102
|
+
*
|
|
103
|
+
* The renderer is a thunk because the surface is decided first: a command asked for JSON
|
|
104
|
+
* must not pay to render text nobody reads, and a caller cannot forget the branch.
|
|
105
|
+
*/
|
|
106
|
+
export function present(payload, renderText, flags) {
|
|
107
|
+
if (isJson(flags)) {
|
|
108
|
+
emitJson(payload);
|
|
109
|
+
return 'json';
|
|
110
|
+
}
|
|
111
|
+
emit(renderText());
|
|
112
|
+
return 'text';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function emit(text, { maxBytes = maxBytesDefault, stream = process.stdout } = {}) {
|
|
96
116
|
const body = text.endsWith('\n') ? text : `${text}\n`;
|
|
97
117
|
const bytes = Buffer.byteLength(body);
|
|
98
118
|
if (maxBytes > 0 && bytes > maxBytes) {
|
|
@@ -131,6 +151,19 @@ export const SCHEMA_VERSION = 1;
|
|
|
131
151
|
*/
|
|
132
152
|
let prettyDefault = process.stdout.isTTY === true;
|
|
133
153
|
|
|
154
|
+
// The byte ceiling is one flag with one reader: the envelope. Commands used to pass
|
|
155
|
+
// `flags.maxBytes ?? 0` at seventy-eight call sites, which is seventy-eight chances to pass
|
|
156
|
+
// a different number, or to forget and answer past the ceiling the caller set (review 0003
|
|
157
|
+
// Q65). A ceiling of zero or a value that is not a number is no ceiling, the same way the
|
|
158
|
+
// flag itself is read.
|
|
159
|
+
let maxBytesDefault = 0;
|
|
160
|
+
|
|
161
|
+
/** Set the byte ceiling every emit and every document inherits, from `--max-bytes`. */
|
|
162
|
+
export function maxBytesLimit(bytes) {
|
|
163
|
+
const value = Number(bytes);
|
|
164
|
+
maxBytesDefault = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
134
167
|
export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
|
|
135
168
|
prettyDefault = pretty;
|
|
136
169
|
}
|
|
@@ -203,8 +236,83 @@ function fitJsonArrays(payload, maxBytes, pretty) {
|
|
|
203
236
|
return null;
|
|
204
237
|
}
|
|
205
238
|
|
|
206
|
-
|
|
207
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Warnings a run accumulated, carried into the JSON document it prints.
|
|
241
|
+
*
|
|
242
|
+
* The same reason stderr is separate from stdout: a caller reading a document cannot
|
|
243
|
+
* see a note printed beside it. A command that ignored half of what it was asked still
|
|
244
|
+
* has to succeed or fail the same way, so the note belongs IN the result, and the text
|
|
245
|
+
* mode keeps the stderr line it already had (review 0003 Q62).
|
|
246
|
+
*/
|
|
247
|
+
let accumulated = [];
|
|
248
|
+
|
|
249
|
+
// The path the dispatcher resolved. A command should not restate it: the ones that did
|
|
250
|
+
// restated it wrongly (`plugin` printed `plugin claude`, `sync` printed `sync --check`),
|
|
251
|
+
// and a document whose `command` disagrees with the argv that produced it is worse than
|
|
252
|
+
// one with no `command` at all (review 0003 Q65).
|
|
253
|
+
let canonicalCommand = null;
|
|
254
|
+
|
|
255
|
+
/** Set the command path every JSON document will carry, or clear it with null. */
|
|
256
|
+
export function jsonCommand(path) {
|
|
257
|
+
canonicalCommand = path ?? null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A document cannot name the exit code before the command has returned one. The
|
|
261
|
+
// dispatcher captures what a command emits, then writes it once with the code attached;
|
|
262
|
+
// writing first is how `ok: false` shipped alongside exit 0 (review 0003 Q65).
|
|
263
|
+
let captured = null;
|
|
264
|
+
|
|
265
|
+
/** Collect documents instead of writing them, so the dispatcher can add the exit code. */
|
|
266
|
+
export function jsonCaptureStart() {
|
|
267
|
+
captured = [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The captured documents and a reset, or an empty list when nothing was captured. */
|
|
271
|
+
export function jsonCaptureTake() {
|
|
272
|
+
const docs = captured;
|
|
273
|
+
captured = null;
|
|
274
|
+
return docs;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Record a warning for the document this run prints. */
|
|
278
|
+
export function warn(message) {
|
|
279
|
+
accumulated = [...accumulated, message];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Drop what a previous run in this process recorded.
|
|
284
|
+
*
|
|
285
|
+
* The collector is per process and a process can dispatch more than once - the MCP
|
|
286
|
+
* server and the tests both do. Without this, one command's notice appeared in the
|
|
287
|
+
* next command's document, which parity between the two surfaces caught (review 0003
|
|
288
|
+
* Q62).
|
|
289
|
+
*/
|
|
290
|
+
export function clearWarnings() {
|
|
291
|
+
accumulated = [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The warnings this run has recorded, in order. */
|
|
295
|
+
export function warnings() {
|
|
296
|
+
return accumulated;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = maxBytesDefault } = {}) {
|
|
300
|
+
// `notices`, not `warnings`: `status` already reports a warning COUNT under that key,
|
|
301
|
+
// and a document cannot mean two things by one name. The collision is named here
|
|
302
|
+
// rather than bent into either shape (review 0003 Q62).
|
|
303
|
+
const carried =
|
|
304
|
+
accumulated.length > 0 && value !== null && typeof value === 'object' && !Array.isArray(value) && value.notices === undefined
|
|
305
|
+
? { ...value, notices: accumulated }
|
|
306
|
+
: value;
|
|
307
|
+
const stamped =
|
|
308
|
+
canonicalCommand !== null && carried !== null && typeof carried === 'object' && !Array.isArray(carried)
|
|
309
|
+
? { ...carried, command: canonicalCommand }
|
|
310
|
+
: carried;
|
|
311
|
+
if (captured !== null) {
|
|
312
|
+
captured.push({ value: stamped });
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
const payload = withSchemaVersion(stamped);
|
|
208
316
|
const text = `${stringifyJson(payload, pretty)}\n`;
|
|
209
317
|
// JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
|
|
210
318
|
// true. Arrays shrink with a `truncated` record naming what lost how much, and a
|
package/src/packs/manage.mjs
CHANGED
|
@@ -511,6 +511,23 @@ export async function publishPack(dir, { apply = false } = {}) {
|
|
|
511
511
|
return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
// A preview is a preview. The registry read below is the one network call in this
|
|
515
|
+
// command, and running it to print a plan made `pack publish` — a preview by default
|
|
516
|
+
// — a network operation that failed offline without `--yes` (review 0003 Q58).
|
|
517
|
+
if (!apply) {
|
|
518
|
+
return {
|
|
519
|
+
pack: linted.pack,
|
|
520
|
+
ok: true,
|
|
521
|
+
published: false,
|
|
522
|
+
skipped: false,
|
|
523
|
+
planned: [
|
|
524
|
+
`npm view ${pkg}@${pack.version} (check whether it is already live)`,
|
|
525
|
+
`npm publish (in ${dir})`,
|
|
526
|
+
],
|
|
527
|
+
findings,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
514
531
|
// Idempotent, the way the release twin is: a version that is already live is
|
|
515
532
|
// skipped rather than republished, so a re-run after an interruption continues
|
|
516
533
|
// where it stopped.
|
|
@@ -534,9 +551,6 @@ export async function publishPack(dir, { apply = false } = {}) {
|
|
|
534
551
|
}
|
|
535
552
|
|
|
536
553
|
const planned = [`npm publish (in ${dir})`];
|
|
537
|
-
if (!apply) {
|
|
538
|
-
return { pack: linted.pack, ok: true, published: false, skipped: false, planned, findings };
|
|
539
|
-
}
|
|
540
554
|
try {
|
|
541
555
|
await exec('npm', ['publish'], { cwd: dir });
|
|
542
556
|
} catch (error) {
|
package/src/packs.mjs
CHANGED
|
@@ -72,8 +72,26 @@ export async function packDigest(dir, { exclude = [] } = {}) {
|
|
|
72
72
|
}
|
|
73
73
|
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
74
74
|
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
75
|
-
if (entry.isDirectory())
|
|
76
|
-
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
await walk(join(d, entry.name), rel);
|
|
77
|
+
} else if (entry.isSymbolicLink()) {
|
|
78
|
+
// A link is not vendored bytes, and the loader follows it: the old walk skipped
|
|
79
|
+
// links while the projection read through them, so a rule reached by a link was
|
|
80
|
+
// projected without being covered by the pin (review 0003 Q58). A link to a
|
|
81
|
+
// file is hashed through its target, because `readFile` below follows it. A link
|
|
82
|
+
// to a directory is refused: recursing would let a pack's digest describe a tree
|
|
83
|
+
// it does not carry.
|
|
84
|
+
const target = await stat(join(d, entry.name)).catch(() => null);
|
|
85
|
+
if (target?.isDirectory()) {
|
|
86
|
+
const error = new Error(
|
|
87
|
+
`${rel} is a symbolic link to a directory; a pack is vendored bytes, and its digest cannot cover a tree it does not carry`,
|
|
88
|
+
);
|
|
89
|
+
error.code = 'PACK_SYMLINK';
|
|
90
|
+
error.path = rel;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (!excluded.has(rel)) files.push(rel);
|
|
94
|
+
} else if (entry.isFile() && !excluded.has(rel)) files.push(rel);
|
|
77
95
|
}
|
|
78
96
|
};
|
|
79
97
|
await walk(dir);
|