simt-dev 0.6.3 → 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 +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- 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-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 +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- 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 +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- 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 +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- 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 +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- 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 +296 -77
- 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 +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- 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 +21 -14
- package/src/projection/manifest.mjs +69 -19
- 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/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 +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/mcp/tools.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { loadRegistry } from '../graph/load.mjs';
|
|
23
23
|
import { provenanceDigest } from '../projection/project.mjs';
|
|
24
24
|
import { discover } from '../compare/discover.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
|
|
26
26
|
import { GATES } from '../hooks/gates.mjs';
|
|
27
27
|
import { checkRepo } from '../commands/sync.mjs';
|
|
28
28
|
import { diagnose, serialisedFindings } from '../commands/doctor.mjs';
|
|
@@ -381,6 +381,9 @@ const output = {
|
|
|
381
381
|
repos: { type: 'array', items: { type: 'object' } },
|
|
382
382
|
worktrees: { type: 'array', items: { type: 'object' } },
|
|
383
383
|
directories: { type: 'array', items: { type: 'string' } },
|
|
384
|
+
partial: { type: 'boolean' },
|
|
385
|
+
cursor: { type: ['string', 'null'] },
|
|
386
|
+
total: { type: 'number' },
|
|
384
387
|
},
|
|
385
388
|
required: ['root', 'shape', 'expectedSha', 'summary', 'repos'],
|
|
386
389
|
},
|
|
@@ -548,7 +551,13 @@ export const TOOLS = [
|
|
|
548
551
|
name: 'skills_index',
|
|
549
552
|
description:
|
|
550
553
|
'Full skill catalogue: every skill with summary, size, stack tags and deferred flag. The fallback when brief returns nothing useful.',
|
|
551
|
-
inputSchema:
|
|
554
|
+
inputSchema: {
|
|
555
|
+
type: 'object',
|
|
556
|
+
properties: {
|
|
557
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
558
|
+
},
|
|
559
|
+
required: [],
|
|
560
|
+
},
|
|
552
561
|
async run(args) {
|
|
553
562
|
const { registry: reg, verified, workspace } = await registry(args);
|
|
554
563
|
return {
|
|
@@ -578,6 +587,7 @@ export const TOOLS = [
|
|
|
578
587
|
budget: { type: 'number', description: 'context budget in bytes (default 20480)' },
|
|
579
588
|
skill: { type: 'string', description: 'force a skill in, comma-separated' },
|
|
580
589
|
all: { type: 'boolean', description: 'ignore the budget' },
|
|
590
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
581
591
|
},
|
|
582
592
|
required: [],
|
|
583
593
|
},
|
|
@@ -610,6 +620,7 @@ export const TOOLS = [
|
|
|
610
620
|
name: { type: 'string' },
|
|
611
621
|
root: { type: 'string' },
|
|
612
622
|
maxBytes: { type: 'number', description: 'hard ceiling on the returned text' },
|
|
623
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
613
624
|
},
|
|
614
625
|
required: ['name'],
|
|
615
626
|
},
|
|
@@ -632,11 +643,16 @@ export const TOOLS = [
|
|
|
632
643
|
description: 'Raw search across capability, keyword and summary. Use when skills_brief has nothing and you suspect a skill exists.',
|
|
633
644
|
inputSchema: {
|
|
634
645
|
type: 'object',
|
|
635
|
-
properties: {
|
|
646
|
+
properties: {
|
|
647
|
+
query: { type: 'string' },
|
|
648
|
+
root: { type: 'string' },
|
|
649
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
650
|
+
},
|
|
636
651
|
required: ['query'],
|
|
637
652
|
},
|
|
638
653
|
async run(args) {
|
|
639
|
-
|
|
654
|
+
const root = rootOf(args);
|
|
655
|
+
return skillsFindFacts(root, typeof args?.query === 'string' ? args.query : '', args ?? {});
|
|
640
656
|
},
|
|
641
657
|
},
|
|
642
658
|
|
|
@@ -647,7 +663,12 @@ export const TOOLS = [
|
|
|
647
663
|
'The signal chain for one skill in one repository: its score, the reasons, whether the stack gate admits it, and what would select it.',
|
|
648
664
|
inputSchema: {
|
|
649
665
|
type: 'object',
|
|
650
|
-
properties: {
|
|
666
|
+
properties: {
|
|
667
|
+
name: { type: 'string' },
|
|
668
|
+
root: { type: 'string' },
|
|
669
|
+
task: { type: 'string' },
|
|
670
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
671
|
+
},
|
|
651
672
|
required: ['name'],
|
|
652
673
|
},
|
|
653
674
|
async run(args) {
|
|
@@ -710,23 +731,37 @@ export const TOOLS = [
|
|
|
710
731
|
outputSchema: output.compare,
|
|
711
732
|
name: 'compare',
|
|
712
733
|
description:
|
|
713
|
-
'The workspace matrix:
|
|
734
|
+
'The workspace matrix: adoption, region, SHA, hooks and gates per repo, plus problems. Pages with limit/cursor; honors progressToken and cancellation.',
|
|
714
735
|
inputSchema: {
|
|
715
736
|
type: 'object',
|
|
716
737
|
properties: {
|
|
717
738
|
root: { type: 'string', description: 'a workspace directory, or one repository' },
|
|
718
739
|
workspace: { type: 'string', description: 'workspace root when root is not given' },
|
|
740
|
+
limit: { type: 'number', description: 'page size; without it every row is built' },
|
|
741
|
+
cursor: { type: 'string', description: 'opaque page start from a previous partial answer' },
|
|
742
|
+
concurrency: { type: 'number', description: 'rows built at once (default 4)' },
|
|
719
743
|
},
|
|
720
744
|
required: [],
|
|
721
745
|
},
|
|
722
|
-
async run(args) {
|
|
746
|
+
async run(args, ctx = {}) {
|
|
723
747
|
const root = args?.root ?? args?.workspace ?? (await resolveWorkspace(args ?? {})).root ?? process.cwd();
|
|
724
748
|
const found = await discover(root);
|
|
725
749
|
if (found.error) throw Object.assign(new Error(`${root}: ${found.error}`), { remedy: 'pass root' });
|
|
726
750
|
const expectedSha = await provenanceDigest();
|
|
727
751
|
const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
|
|
728
|
-
|
|
729
|
-
|
|
752
|
+
// A page names its start as an index into discovery order, which is
|
|
753
|
+
// stable between calls on an unchanged estate; garbage parses to 0
|
|
754
|
+
// rather than erroring, because a dashboard that refuses to run over a
|
|
755
|
+
// stale cursor is worse than one that restarts the listing.
|
|
756
|
+
const start = Math.max(0, parseInt(typeof args?.cursor === 'string' ? args.cursor : '0', 10) || 0);
|
|
757
|
+
const limit = typeof args?.limit === 'number' && args.limit >= 1 ? Math.floor(args.limit) : null;
|
|
758
|
+
const page = limit === null ? found.repos : found.repos.slice(start, start + limit);
|
|
759
|
+
const rows = await buildRows(page, context, {
|
|
760
|
+
concurrency: resolveConcurrency(args ?? {}),
|
|
761
|
+
signal: ctx?.signal ?? null,
|
|
762
|
+
onBatch: ctx?.onProgress ? (done, total) => ctx.onProgress(start + done, found.repos.length) : null,
|
|
763
|
+
});
|
|
764
|
+
const next = limit !== null && start + page.length < found.repos.length ? String(start + page.length) : null;
|
|
730
765
|
return {
|
|
731
766
|
root,
|
|
732
767
|
shape: found.shape,
|
|
@@ -735,6 +770,7 @@ export const TOOLS = [
|
|
|
735
770
|
repos: rows,
|
|
736
771
|
worktrees: found.worktrees.map((w) => ({ name: w.name, owner: w.ownerName })),
|
|
737
772
|
directories: found.directories,
|
|
773
|
+
...(limit === null ? {} : { partial: next !== null, cursor: next, total: found.repos.length }),
|
|
738
774
|
};
|
|
739
775
|
},
|
|
740
776
|
},
|
package/src/memory/decide.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { execFile } from 'node:child_process';
|
|
|
7
7
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
|
-
import {
|
|
10
|
+
import { recordCreatedFile } from '../journal.mjs';
|
|
11
|
+
import { decisionsDir } from '../decisions/dir.mjs';
|
|
11
12
|
|
|
12
13
|
const exec = promisify(execFile);
|
|
13
14
|
|
|
@@ -336,15 +337,19 @@ function renderDecisionScaffold({
|
|
|
336
337
|
].join('\n');
|
|
337
338
|
}
|
|
338
339
|
|
|
339
|
-
/**
|
|
340
|
+
/**
|
|
341
|
+
* Scaffold a new ADR in the repository's decisions directory with structured frontmatter.
|
|
342
|
+
*
|
|
343
|
+
* The number is claimed with `O_EXCL`: two concurrent scaffolds that both read
|
|
344
|
+
* the same maximum must not land on the same number, and a check-then-write
|
|
345
|
+
* would let the second silently overwrite the first. On collision the number is
|
|
346
|
+
* re-read and the next free one taken, so the retry always terminates.
|
|
347
|
+
*/
|
|
340
348
|
export async function scaffoldDecision(root, options = {}) {
|
|
341
|
-
const
|
|
342
|
-
const
|
|
349
|
+
const relDir = options.decisionsDir ?? (await decisionsDir(root));
|
|
350
|
+
const absDir = join(root, relDir);
|
|
343
351
|
const title = String(options.title ?? 'Untitled decision').trim() || 'Untitled decision';
|
|
344
352
|
const slug = slugifyTitle(title);
|
|
345
|
-
const file = `${id}-${slug}.md`;
|
|
346
|
-
const relPath = ['docs', 'decisions', file].join('/');
|
|
347
|
-
const fullPath = join(decisionsDir, file);
|
|
348
353
|
const affects = Array.isArray(options.affects)
|
|
349
354
|
? options.affects.map((p) => String(p).trim()).filter(Boolean)
|
|
350
355
|
: await diffAffectedPaths(root);
|
|
@@ -352,38 +357,58 @@ export async function scaffoldDecision(root, options = {}) {
|
|
|
352
357
|
const { entries: supersedes, errors } = parseSupersedesSpec(options.supersedes);
|
|
353
358
|
if (errors.length > 0) return { ok: false, errors };
|
|
354
359
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
360
|
+
// A dry run names the number without claiming it: the file must not exist
|
|
361
|
+
// afterwards, so no exclusive create happens here.
|
|
362
|
+
if (!options.write) {
|
|
363
|
+
const { number, id } = await nextDecisionNumber(absDir);
|
|
364
|
+
const file = `${id}-${slug}.md`;
|
|
365
|
+
return {
|
|
366
|
+
ok: true,
|
|
367
|
+
id,
|
|
368
|
+
number,
|
|
369
|
+
title,
|
|
370
|
+
slug,
|
|
371
|
+
file,
|
|
372
|
+
relPath: [relDir, file].join('/'),
|
|
373
|
+
path: join(absDir, file),
|
|
374
|
+
affects,
|
|
375
|
+
supersedes,
|
|
376
|
+
text: renderDecisionScaffold({ id, title, date: options.date, status: options.status, affects, supersedes }),
|
|
377
|
+
written: false,
|
|
378
|
+
};
|
|
371
379
|
}
|
|
372
380
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
id
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
381
|
+
await mkdir(absDir, { recursive: true });
|
|
382
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
383
|
+
const { number, id } = await nextDecisionNumber(absDir);
|
|
384
|
+
const file = `${id}-${slug}.md`;
|
|
385
|
+
const fullPath = join(absDir, file);
|
|
386
|
+
const text = renderDecisionScaffold({ id, title, date: options.date, status: options.status, affects, supersedes });
|
|
387
|
+
try {
|
|
388
|
+
await writeFile(fullPath, text, { flag: 'wx' });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error.code !== 'EEXIST') throw error;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
// The exclusive create is the journal entry's `before: null`: the file did
|
|
394
|
+
// not exist a moment ago, by construction rather than by re-reading.
|
|
395
|
+
if (options.journal) recordCreatedFile(root, options.journal, fullPath, text);
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
id,
|
|
399
|
+
number,
|
|
400
|
+
title,
|
|
401
|
+
slug,
|
|
402
|
+
file,
|
|
403
|
+
relPath: [relDir, file].join('/'),
|
|
404
|
+
path: fullPath,
|
|
405
|
+
affects,
|
|
406
|
+
supersedes,
|
|
407
|
+
text,
|
|
408
|
+
written: true,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return { ok: false, errors: ['could not claim a free ADR number after 100 attempts'] };
|
|
387
412
|
}
|
|
388
413
|
|
|
389
414
|
/** Extract partial supersession claims stated in prose (`Status:` and header paragraphs). */
|
|
@@ -415,8 +440,15 @@ function prosePartialSupersessions(body) {
|
|
|
415
440
|
return claims;
|
|
416
441
|
}
|
|
417
442
|
|
|
418
|
-
/**
|
|
419
|
-
|
|
443
|
+
/**
|
|
444
|
+
* Lint a single ADR document for structured supersession conformance (plan 0003 R34).
|
|
445
|
+
*
|
|
446
|
+
* `historicalCutoff` exempts the first N records from the frontmatter requirement:
|
|
447
|
+
* they predate it, and demanding it retroactively would fail a history nobody can
|
|
448
|
+
* rewrite without rewriting history. Q55: the number lives in the repository's
|
|
449
|
+
* config (`decisions.historicalCutoff`, resolved by the caller), never in code.
|
|
450
|
+
*/
|
|
451
|
+
export function lintDecision(text, { file = 'ADR-000.md', historicalCutoff = 0 } = {}) {
|
|
420
452
|
const fileMatch = file !== 'ADR-000.md' ? /^(ADR-\d{3,})/i.exec(file) : null;
|
|
421
453
|
const headMatch = /^#\s+(ADR-\d{3,})\b/m.exec(String(text ?? ''));
|
|
422
454
|
const id = (fileMatch?.[1] ?? headMatch?.[1] ?? /^(ADR-\d{3,})/i.exec(file)?.[1] ?? '').toUpperCase() || null;
|
|
@@ -432,7 +464,7 @@ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = fals
|
|
|
432
464
|
remedy: 'fix simt-supersedes frontmatter to use [{ adr: "ADR-NNN", ruling: N }]',
|
|
433
465
|
}));
|
|
434
466
|
|
|
435
|
-
if (
|
|
467
|
+
if (!fm.hasFrontmatter && num !== null && num > 0 && num <= historicalCutoff) {
|
|
436
468
|
return { ok: findings.length === 0, findings };
|
|
437
469
|
}
|
|
438
470
|
|
|
@@ -455,13 +487,36 @@ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = fals
|
|
|
455
487
|
return { ok: findings.length === 0, findings };
|
|
456
488
|
}
|
|
457
489
|
|
|
458
|
-
/**
|
|
459
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Lint all ADR records in a decisions directory.
|
|
492
|
+
*
|
|
493
|
+
* Duplicate ids are a lint finding, not a silent keep-first: two files claiming
|
|
494
|
+
* one number means `byId` answers for only one of them, and the reader of the
|
|
495
|
+
* other gets a record the index cannot see. The kept record is the first in
|
|
496
|
+
* numeric filename order — the same one the loader keeps — so lint and load
|
|
497
|
+
* never name different winners.
|
|
498
|
+
*/
|
|
499
|
+
export async function lintDecisions(decisionsDir, { historicalCutoff = 0 } = {}) {
|
|
460
500
|
const names = (await readdir(decisionsDir).catch(() => [])).filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n)).sort();
|
|
461
501
|
const findings = [];
|
|
502
|
+
const seen = new Map();
|
|
462
503
|
for (const file of names) {
|
|
504
|
+
const id = /^(ADR-\d{3,})/i.exec(file)?.[1]?.toUpperCase() ?? null;
|
|
505
|
+
if (id !== null) {
|
|
506
|
+
if (seen.has(id)) {
|
|
507
|
+
findings.push({
|
|
508
|
+
file,
|
|
509
|
+
id,
|
|
510
|
+
status: 'duplicate-decision-id',
|
|
511
|
+
detail: `decision id ${id} is claimed by ${seen.get(id)} and ${file}; the index only sees the first`,
|
|
512
|
+
remedy: 'renumber the newer record to a free number, then rerun the check',
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
seen.set(id, file);
|
|
517
|
+
}
|
|
463
518
|
const text = await readFile(join(decisionsDir, file), 'utf8');
|
|
464
|
-
findings.push(...lintDecision(text, { file,
|
|
519
|
+
findings.push(...lintDecision(text, { file, historicalCutoff }).findings);
|
|
465
520
|
}
|
|
466
521
|
return { ok: findings.length === 0, findings };
|
|
467
522
|
}
|