@erclx/aitk 3.15.0 → 3.16.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/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -1
- package/docs/agents/records.md +23 -1
- package/package.json +1 -1
- package/src/commands/records.ts +270 -0
- package/src/records/migrate.ts +78 -0
- package/src/records/validate.ts +32 -5
package/docs/agents/commands.md
CHANGED
|
@@ -37,6 +37,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
37
37
|
| `aitk teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
|
|
38
38
|
| `aitk teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
|
|
39
39
|
| `aitk records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
|
|
40
|
+
| `aitk records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
|
|
40
41
|
| `aitk records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
|
|
41
42
|
| `aitk records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
|
|
42
43
|
| `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
|
package/docs/agents/index.md
CHANGED
|
@@ -24,7 +24,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
24
24
|
- [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
|
|
25
25
|
- [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
|
|
26
26
|
- [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
|
|
27
|
-
- [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
27
|
+
- [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
28
28
|
- [Restated instructions](restated.md): Counting the instructions the always-loaded file shares with the seed and the shipped skill bodies, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
|
|
29
29
|
- [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
|
|
30
30
|
- [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
|
package/docs/agents/records.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Records
|
|
3
|
-
description: Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
3
|
+
description: Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Records
|
|
@@ -72,6 +72,28 @@ aitk records validate plans --json | jq -r '.findings[] | "\(.kind): \(.subject)
|
|
|
72
72
|
|
|
73
73
|
For the shapes each check enforces, see `.claude/standards/plan.md`, `.claude/standards/groundwork.md`, `.claude/standards/intake.md`, `.claude/standards/memory.md`, and `.claude/standards/standard.md`.
|
|
74
74
|
|
|
75
|
+
## Migrate
|
|
76
|
+
|
|
77
|
+
`aitk records migrate <kind>` rewrites the records a `validate` finding names a transform for. A standard that redefines its own required frontmatter breaks every record already written to the old shape, and this is the repair `validate` could only report until now.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
aitk records migrate memory
|
|
81
|
+
aitk records migrate memory --write
|
|
82
|
+
aitk records migrate memory --json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Option | Behavior |
|
|
86
|
+
| --------------- | ------------------------------------------------------------------- |
|
|
87
|
+
| `--json` | Add a machine-readable record on stdout |
|
|
88
|
+
| `--write` | Rewrite every record a transform can repair |
|
|
89
|
+
| `--root <path>` | Project root, defaulting to the main worktree except on `standards` |
|
|
90
|
+
|
|
91
|
+
It writes nothing until `--write` is passed, matching the write-flag contract `aitk tooling sync` carries: a session record has no history to undo a wrong repair from, so naming a kind is not consent to rewrite every record inside it. A dry run reports which records it would touch and exits non-zero either way, headless or not, since there is nothing to prompt for.
|
|
92
|
+
|
|
93
|
+
A transform runs only where the missing value is recoverable from the file itself. The one shipped today repairs a memory record missing `category` alone, deriving it from the same filename prefix `checkMemory` already reads it from. `title` and `description` are prose nobody wrote down, so a finding naming either carries no transform and stays for a session to fix by hand, and `validate` keeps reporting it. The transform re-reads the file rather than trusting a value captured at validate time, so a check and its repair cannot disagree about the same record.
|
|
94
|
+
|
|
95
|
+
Exit codes: `0` nothing carried a known transform, or `--write` repaired everything it found. `1` refused for a reason `validate` shares, every candidate it found failed to repair, or `--write` repaired only some of them. `2` a record carries a known transform and `--write` was not passed.
|
|
96
|
+
|
|
75
97
|
## Size
|
|
76
98
|
|
|
77
99
|
`aitk records size` reports what each record folder holds and how much of it is recent. It reads the ten backed folders named under Push and pull, plus `.claude/.tmp`, and it gates nothing.
|
package/package.json
CHANGED
package/src/commands/records.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import type { Command } from 'commander'
|
|
2
4
|
import { BACKED_FOLDERS, pullRecords, pushRecords } from '@/records/backup'
|
|
5
|
+
import { migrateRecord } from '@/records/migrate'
|
|
3
6
|
import {
|
|
4
7
|
type FolderSize,
|
|
5
8
|
formatBytes,
|
|
@@ -10,10 +13,12 @@ import {
|
|
|
10
13
|
} from '@/records/size'
|
|
11
14
|
import {
|
|
12
15
|
type Finding,
|
|
16
|
+
type FindingRemedy,
|
|
13
17
|
isRecordKind,
|
|
14
18
|
isSharedScratch,
|
|
15
19
|
RECORD_KINDS,
|
|
16
20
|
type RecordKind,
|
|
21
|
+
recordsDir,
|
|
17
22
|
type ValidateOutcome,
|
|
18
23
|
validateRecords,
|
|
19
24
|
} from '@/records/validate'
|
|
@@ -32,6 +37,9 @@ import { currentWorktreeRoot, mainWorktreeRoot } from '@/worktree'
|
|
|
32
37
|
/** Returned when a record carries a finding, which is the gating result. */
|
|
33
38
|
const EXIT_FINDINGS = 2
|
|
34
39
|
|
|
40
|
+
/** Returned when a record carries a known transform and `--write` was not passed. */
|
|
41
|
+
const EXIT_MIGRATABLE = 2
|
|
42
|
+
|
|
35
43
|
interface ValidateCommandOptions {
|
|
36
44
|
readonly json?: boolean
|
|
37
45
|
readonly root?: string
|
|
@@ -39,6 +47,10 @@ interface ValidateCommandOptions {
|
|
|
39
47
|
|
|
40
48
|
type BackupCommandOptions = ValidateCommandOptions
|
|
41
49
|
|
|
50
|
+
interface MigrateCommandOptions extends ValidateCommandOptions {
|
|
51
|
+
readonly write?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
export function register(program: Command): void {
|
|
43
55
|
const records = program
|
|
44
56
|
.command('records')
|
|
@@ -90,6 +102,44 @@ export function register(program: Command): void {
|
|
|
90
102
|
process.exitCode = await runValidate(kind, opts)
|
|
91
103
|
})
|
|
92
104
|
|
|
105
|
+
records
|
|
106
|
+
.command('migrate')
|
|
107
|
+
.description('Rewrite the records a validate finding names a transform for')
|
|
108
|
+
.argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
|
|
109
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
110
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
111
|
+
.option('--write', 'Rewrite every record a transform can repair')
|
|
112
|
+
.option(
|
|
113
|
+
'--root <path>',
|
|
114
|
+
'Project root, defaulting to the main worktree except on standards',
|
|
115
|
+
)
|
|
116
|
+
.addHelpText(
|
|
117
|
+
'after',
|
|
118
|
+
[
|
|
119
|
+
'',
|
|
120
|
+
'Exit codes:',
|
|
121
|
+
' 0 nothing carries a known transform, or --write repaired every one',
|
|
122
|
+
' 1 refused, with the reason on stderr or in the JSON record, every',
|
|
123
|
+
' candidate it found failed to repair, or --write repaired only',
|
|
124
|
+
' some of them',
|
|
125
|
+
' 2 a record carries a known transform and --write was not passed',
|
|
126
|
+
'',
|
|
127
|
+
'It reports and never writes without --write, matching aitk records',
|
|
128
|
+
'validate: a session record has no history to undo a wrong repair from.',
|
|
129
|
+
'A transform is only offered where the old value is recoverable from the',
|
|
130
|
+
'file itself, so a finding with no transform is left for a session to fix.',
|
|
131
|
+
'',
|
|
132
|
+
'Examples:',
|
|
133
|
+
' aitk records migrate memory',
|
|
134
|
+
' aitk records migrate memory --write',
|
|
135
|
+
' aitk records migrate memory --json',
|
|
136
|
+
'',
|
|
137
|
+
].join('\n'),
|
|
138
|
+
)
|
|
139
|
+
.action(async (kind: string, opts: MigrateCommandOptions) => {
|
|
140
|
+
process.exitCode = await runMigrate(kind, opts)
|
|
141
|
+
})
|
|
142
|
+
|
|
93
143
|
records
|
|
94
144
|
.command('size')
|
|
95
145
|
.description('Report what each record folder holds and how much is recent')
|
|
@@ -456,3 +506,223 @@ function describe(found: Finding): string {
|
|
|
456
506
|
const scope = found.record === found.subject ? '' : `${found.record}: `
|
|
457
507
|
return `${scope}${found.subject} ${found.message}`
|
|
458
508
|
}
|
|
509
|
+
|
|
510
|
+
export interface Repair {
|
|
511
|
+
readonly record: string
|
|
512
|
+
readonly remedy: FindingRemedy
|
|
513
|
+
readonly path: string
|
|
514
|
+
readonly text: string
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export interface Refusal {
|
|
518
|
+
readonly record: string
|
|
519
|
+
readonly message: string
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The message a rejected promise leaves, for a record whose read or write failed. */
|
|
523
|
+
function describeFailure(reason: unknown): string {
|
|
524
|
+
return reason instanceof Error ? reason.message : String(reason)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Runs every finding's transform, without writing anything back.
|
|
529
|
+
*
|
|
530
|
+
* `Promise.allSettled` rather than `Promise.all`, so a record whose read
|
|
531
|
+
* fails, such as one deleted between `validate`'s listing and this read,
|
|
532
|
+
* becomes a refusal for that one record rather than an unhandled rejection
|
|
533
|
+
* that `program.parse()` has no top-level catch for.
|
|
534
|
+
*/
|
|
535
|
+
async function attemptMigrations(
|
|
536
|
+
dir: string,
|
|
537
|
+
findings: readonly Finding[],
|
|
538
|
+
): Promise<{ repaired: Repair[]; refused: Refusal[] }> {
|
|
539
|
+
const candidates = findings.filter(
|
|
540
|
+
(found): found is Finding & { readonly remedy: FindingRemedy } =>
|
|
541
|
+
found.remedy !== undefined,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
const settled = await Promise.allSettled(
|
|
545
|
+
candidates.map(async (found) => {
|
|
546
|
+
const path = join(dir, found.record)
|
|
547
|
+
const outcome = migrateRecord(
|
|
548
|
+
found.remedy,
|
|
549
|
+
found.record,
|
|
550
|
+
await readFile(path, 'utf8'),
|
|
551
|
+
)
|
|
552
|
+
return { record: found.record, remedy: found.remedy, path, outcome }
|
|
553
|
+
}),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
const repaired: Repair[] = []
|
|
557
|
+
const refused: Refusal[] = []
|
|
558
|
+
|
|
559
|
+
settled.forEach((result, index) => {
|
|
560
|
+
if (result.status === 'rejected') {
|
|
561
|
+
refused.push({
|
|
562
|
+
record: candidates[index].record,
|
|
563
|
+
message: `could not be read: ${describeFailure(result.reason)}`,
|
|
564
|
+
})
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const { record, remedy, path, outcome } = result.value
|
|
569
|
+
|
|
570
|
+
if (outcome.ok) {
|
|
571
|
+
repaired.push({ record, remedy, path, text: outcome.text })
|
|
572
|
+
} else {
|
|
573
|
+
refused.push({ record, message: outcome.message })
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
return { repaired, refused }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Writes every repair, independently. `Promise.allSettled` so one record's
|
|
582
|
+
* write failing does not abort the writes that would otherwise have
|
|
583
|
+
* succeeded, per the concurrency standard's rule on batched partial failure.
|
|
584
|
+
*/
|
|
585
|
+
async function writeRepairs(
|
|
586
|
+
repaired: readonly Repair[],
|
|
587
|
+
): Promise<{ written: Repair[]; failed: Refusal[] }> {
|
|
588
|
+
const settled = await Promise.allSettled(
|
|
589
|
+
repaired.map((entry) => writeFile(entry.path, entry.text, 'utf8')),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
const written: Repair[] = []
|
|
593
|
+
const failed: Refusal[] = []
|
|
594
|
+
|
|
595
|
+
settled.forEach((result, index) => {
|
|
596
|
+
const entry = repaired[index]
|
|
597
|
+
|
|
598
|
+
if (result.status === 'fulfilled') {
|
|
599
|
+
written.push(entry)
|
|
600
|
+
} else {
|
|
601
|
+
failed.push({
|
|
602
|
+
record: entry.record,
|
|
603
|
+
message: `could not be written: ${describeFailure(result.reason)}`,
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
return { written, failed }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function runMigrate(
|
|
612
|
+
kind: string,
|
|
613
|
+
opts: MigrateCommandOptions,
|
|
614
|
+
): Promise<number> {
|
|
615
|
+
const emitJson = opts.json ?? false
|
|
616
|
+
|
|
617
|
+
if (!isRecordKind(kind)) {
|
|
618
|
+
return reportRefusal(
|
|
619
|
+
'aitk records migrate',
|
|
620
|
+
{
|
|
621
|
+
reason: 'unknown-kind',
|
|
622
|
+
message: `Not a record kind: ${kind}. Expected one of: ${RECORD_KINDS.join(', ')}.`,
|
|
623
|
+
},
|
|
624
|
+
emitJson,
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const root = opts.root ?? (await defaultRoot(kind))
|
|
629
|
+
const outcome = await validateRecords(root, kind)
|
|
630
|
+
|
|
631
|
+
if (!outcome.ok) {
|
|
632
|
+
return reportRefusal('aitk records migrate', outcome, emitJson)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const write = opts.write ?? false
|
|
636
|
+
const { repaired, refused } = await attemptMigrations(
|
|
637
|
+
recordsDir(root, kind),
|
|
638
|
+
outcome.findings,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
if (!write) {
|
|
642
|
+
return reportMigrate(root, outcome.kind, repaired, refused, write, emitJson)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const { written, failed } = await writeRepairs(repaired)
|
|
646
|
+
|
|
647
|
+
return reportMigrate(
|
|
648
|
+
root,
|
|
649
|
+
outcome.kind,
|
|
650
|
+
written,
|
|
651
|
+
[...refused, ...failed],
|
|
652
|
+
write,
|
|
653
|
+
emitJson,
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function reportMigrate(
|
|
658
|
+
root: string,
|
|
659
|
+
kind: RecordKind,
|
|
660
|
+
repaired: readonly Repair[],
|
|
661
|
+
refused: readonly Refusal[],
|
|
662
|
+
write: boolean,
|
|
663
|
+
emitJson: boolean,
|
|
664
|
+
): number {
|
|
665
|
+
const total = repaired.length + refused.length
|
|
666
|
+
|
|
667
|
+
if (emitJson) {
|
|
668
|
+
process.stdout.write(
|
|
669
|
+
`${JSON.stringify({
|
|
670
|
+
ok: true,
|
|
671
|
+
root,
|
|
672
|
+
kind,
|
|
673
|
+
written: write,
|
|
674
|
+
migrated: repaired.map((entry) => entry.record),
|
|
675
|
+
refused,
|
|
676
|
+
})}\n`,
|
|
677
|
+
)
|
|
678
|
+
} else {
|
|
679
|
+
intro('aitk records migrate')
|
|
680
|
+
|
|
681
|
+
if (total === 0) {
|
|
682
|
+
logStep('Clean')
|
|
683
|
+
logInfo('no finding carries a known transform')
|
|
684
|
+
} else {
|
|
685
|
+
logStep(write ? 'Rewritten' : 'Would rewrite')
|
|
686
|
+
if (repaired.length === 0) {
|
|
687
|
+
logInfo('none, every candidate refused, see below')
|
|
688
|
+
} else {
|
|
689
|
+
for (const entry of repaired)
|
|
690
|
+
logInfo(`${entry.record}: ${entry.remedy}`)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (refused.length > 0) {
|
|
694
|
+
logStep('Refused')
|
|
695
|
+
for (const entry of refused)
|
|
696
|
+
logWarn(`${entry.record}: ${entry.message}`)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!write && repaired.length > 0) {
|
|
700
|
+
logInfo('Re-run with --write to apply.')
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
outro()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return migrateExitCode(repaired, refused, write)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* `repaired` carries only records a transform actually fixed, whether this
|
|
712
|
+
* is a dry run or the set `--write` wrote, so exit 2 (a write is available)
|
|
713
|
+
* never fires when every candidate refused. A dry run reaching that state
|
|
714
|
+
* previously returned the same code as a genuine offer to write, telling a
|
|
715
|
+
* caller `--write` would repair something when it would repair nothing.
|
|
716
|
+
*/
|
|
717
|
+
export function migrateExitCode(
|
|
718
|
+
repaired: readonly Repair[],
|
|
719
|
+
refused: readonly Refusal[],
|
|
720
|
+
write: boolean,
|
|
721
|
+
): number {
|
|
722
|
+
const total = repaired.length + refused.length
|
|
723
|
+
|
|
724
|
+
if (total === 0) return 0
|
|
725
|
+
if (repaired.length === 0) return 1
|
|
726
|
+
if (!write) return EXIT_MIGRATABLE
|
|
727
|
+
return refused.length > 0 ? 1 : 0
|
|
728
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
2
|
+
import {
|
|
3
|
+
CATEGORY_BY_TYPE,
|
|
4
|
+
type FindingRemedy,
|
|
5
|
+
MEMORY_NAME,
|
|
6
|
+
memoryType,
|
|
7
|
+
} from '@/records/validate'
|
|
8
|
+
|
|
9
|
+
export const MIGRATE_REFUSALS = ['field-unrecoverable'] as const
|
|
10
|
+
|
|
11
|
+
export type MigrateRefusal = (typeof MIGRATE_REFUSALS)[number]
|
|
12
|
+
|
|
13
|
+
export interface MigrateRepair {
|
|
14
|
+
readonly ok: true
|
|
15
|
+
readonly text: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MigrateRefused {
|
|
19
|
+
readonly ok: false
|
|
20
|
+
readonly reason: MigrateRefusal
|
|
21
|
+
readonly message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MigrateOutcome = MigrateRepair | MigrateRefused
|
|
25
|
+
|
|
26
|
+
function refuse(message: string): MigrateRefused {
|
|
27
|
+
return { ok: false, reason: 'field-unrecoverable', message }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Repairs the one recoverable shape of `frontmatter-incomplete`: a memory
|
|
32
|
+
* record missing `category` alone. The value sits in the filename's type
|
|
33
|
+
* prefix already, which is the same fact `checkMemory` derives it from, so a
|
|
34
|
+
* repair here writes back what the record already states rather than a value
|
|
35
|
+
* a transform invented. It re-reads the file rather than trusting a value
|
|
36
|
+
* carried on the `Finding`, so a transform and the check it repairs cannot
|
|
37
|
+
* drift apart.
|
|
38
|
+
*/
|
|
39
|
+
function categoryFromName(name: string, text: string): MigrateOutcome {
|
|
40
|
+
const frontmatter = parseFrontmatter(text)
|
|
41
|
+
if (!frontmatter) {
|
|
42
|
+
return refuse(`${name} carries no frontmatter block to add category to.`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (readField(frontmatter, 'category')) {
|
|
46
|
+
return refuse(`${name} already carries a category.`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const match = MEMORY_NAME.exec(name)
|
|
50
|
+
const type = match ? memoryType(match[1]) : undefined
|
|
51
|
+
if (!type) {
|
|
52
|
+
return refuse(
|
|
53
|
+
`${name} is not named <type>-<slug>.md, so category has no type to derive from.`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rewritten = frontmatter.raw.replace(
|
|
58
|
+
/\n---$/,
|
|
59
|
+
`\ncategory: ${CATEGORY_BY_TYPE[type]}\n---`,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return { ok: true, text: rewritten + text.slice(frontmatter.raw.length) }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const TRANSFORMS: Readonly<
|
|
66
|
+
Record<FindingRemedy, (name: string, text: string) => MigrateOutcome>
|
|
67
|
+
> = {
|
|
68
|
+
'category-from-name': categoryFromName,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Applies the transform a finding's `remedy` names against its own text. */
|
|
72
|
+
export function migrateRecord(
|
|
73
|
+
remedy: FindingRemedy,
|
|
74
|
+
name: string,
|
|
75
|
+
text: string,
|
|
76
|
+
): MigrateOutcome {
|
|
77
|
+
return TRANSFORMS[remedy](name, text)
|
|
78
|
+
}
|
package/src/records/validate.ts
CHANGED
|
@@ -72,12 +72,22 @@ export const FINDING_KINDS = [
|
|
|
72
72
|
|
|
73
73
|
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The transforms `migrate.ts` carries. Most finding kinds have none, since a
|
|
77
|
+
* transform is only safe where the old shape is recoverable from the file
|
|
78
|
+
* itself, so this stays optional on `Finding` rather than required.
|
|
79
|
+
*/
|
|
80
|
+
export const FINDING_REMEDIES = ['category-from-name'] as const
|
|
81
|
+
|
|
82
|
+
export type FindingRemedy = (typeof FINDING_REMEDIES)[number]
|
|
83
|
+
|
|
75
84
|
export interface Finding {
|
|
76
85
|
readonly kind: FindingKind
|
|
77
86
|
/** The record the finding sits in, relative to the validated folder. */
|
|
78
87
|
readonly record: string
|
|
79
88
|
readonly subject: string
|
|
80
89
|
readonly message: string
|
|
90
|
+
readonly remedy?: FindingRemedy
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
export interface ValidateReport {
|
|
@@ -134,8 +144,9 @@ function finding(
|
|
|
134
144
|
record: string,
|
|
135
145
|
subject: string,
|
|
136
146
|
message: string,
|
|
147
|
+
remedy?: FindingRemedy,
|
|
137
148
|
): Finding {
|
|
138
|
-
return { kind, record, subject, message }
|
|
149
|
+
return { kind, record, subject, message, remedy }
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
async function listMarkdown(dir: string): Promise<string[]> {
|
|
@@ -748,26 +759,41 @@ const MEMORY_FIELDS = ['title', 'description', 'category'] as const
|
|
|
748
759
|
* catches a prefix outside the set, a field disagreeing with the prefix, and a
|
|
749
760
|
* casing drift that would open a second group in the catalog.
|
|
750
761
|
*/
|
|
751
|
-
const CATEGORY_BY_TYPE = {
|
|
762
|
+
export const CATEGORY_BY_TYPE = {
|
|
752
763
|
feedback: 'Feedback',
|
|
753
764
|
project: 'Project',
|
|
754
765
|
user: 'User',
|
|
755
766
|
reference: 'Reference',
|
|
756
767
|
} as const
|
|
757
768
|
|
|
758
|
-
type MemoryType = keyof typeof CATEGORY_BY_TYPE
|
|
769
|
+
export type MemoryType = keyof typeof CATEGORY_BY_TYPE
|
|
759
770
|
|
|
760
771
|
const MEMORY_TYPES = Object.keys(CATEGORY_BY_TYPE) as readonly MemoryType[]
|
|
761
772
|
|
|
762
|
-
const MEMORY_NAME = /^([a-z]+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/
|
|
773
|
+
export const MEMORY_NAME = /^([a-z]+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/
|
|
763
774
|
|
|
764
775
|
/** The two markers a rule-bearing body carries, on top of the rule line itself. */
|
|
765
776
|
const MEMORY_MARKERS = ['**Why:**', '**How to apply:**'] as const
|
|
766
777
|
|
|
767
|
-
function memoryType(value: string): MemoryType | undefined {
|
|
778
|
+
export function memoryType(value: string): MemoryType | undefined {
|
|
768
779
|
return MEMORY_TYPES.find((type) => type === value)
|
|
769
780
|
}
|
|
770
781
|
|
|
782
|
+
/**
|
|
783
|
+
* The one missing-field shape `migrate.ts` can repair: `category` alone,
|
|
784
|
+
* recoverable from the same filename prefix `checkMemory` already read it
|
|
785
|
+
* from. `title` and `description` are prose nobody wrote down, so a finding
|
|
786
|
+
* naming either carries no remedy.
|
|
787
|
+
*/
|
|
788
|
+
function memoryRemedy(
|
|
789
|
+
missing: readonly string[],
|
|
790
|
+
named: MemoryType | undefined,
|
|
791
|
+
): FindingRemedy | undefined {
|
|
792
|
+
return named && missing.length === 1 && missing[0] === 'category'
|
|
793
|
+
? 'category-from-name'
|
|
794
|
+
: undefined
|
|
795
|
+
}
|
|
796
|
+
|
|
771
797
|
export function checkMemory(name: string, text: string): Finding[] {
|
|
772
798
|
const findings: Finding[] = []
|
|
773
799
|
const match = MEMORY_NAME.exec(name)
|
|
@@ -796,6 +822,7 @@ export function checkMemory(name: string, text: string): Finding[] {
|
|
|
796
822
|
name,
|
|
797
823
|
name,
|
|
798
824
|
`carries no ${missing.join(' and no ')}.`,
|
|
825
|
+
memoryRemedy(missing, named),
|
|
799
826
|
),
|
|
800
827
|
)
|
|
801
828
|
}
|