@erclx/aitk 0.99.1 → 0.101.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.
@@ -1,8 +1,16 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
5
  import { linesOutsideFences } from '@/markdown/scan'
6
+ import {
7
+ TEACH_GLOSSARY,
8
+ TEACH_MISSION,
9
+ TEACH_RECORDS,
10
+ TEACH_REFERENCE,
11
+ TEACH_RESOURCES,
12
+ WORKSPACE_NAME,
13
+ } from '@/teach/workspace'
6
14
 
7
15
  export const RECORD_KINDS = [
8
16
  'plans',
@@ -10,6 +18,7 @@ export const RECORD_KINDS = [
10
18
  'intake',
11
19
  'memory',
12
20
  'standards',
21
+ 'teach',
13
22
  ] as const
14
23
 
15
24
  export type RecordKind = (typeof RECORD_KINDS)[number]
@@ -29,6 +38,7 @@ const FOLDERS_BY_KIND: Readonly<Record<RecordKind, readonly string[]>> = {
29
38
  intake: [join('.claude', 'intake')],
30
39
  memory: [join('.claude', 'memory')],
31
40
  standards: ['standards', join('.claude', 'standards')],
41
+ teach: [join('.claude', 'teach')],
32
42
  }
33
43
 
34
44
  /**
@@ -106,7 +116,7 @@ export function isRecordKind(value: string): value is RecordKind {
106
116
  /**
107
117
  * Whether a kind's folder is shared session scratch at the main worktree root.
108
118
  *
109
- * The four record folders are, so every session validates the records every
119
+ * The five record folders are, so every session validates the records every
110
120
  * other session reads. The corpus is tracked instead, so a linked worktree holds
111
121
  * its own edited copy, and defaulting that kind to the main root would report on
112
122
  * a tree the session never touched and say nothing about which one it read.
@@ -566,6 +576,167 @@ async function checkDump(dir: string, slug: string): Promise<Finding[]> {
566
576
  return [...findings, ...perCluster.flat()]
567
577
  }
568
578
 
579
+ const TEACH_SUCCESS = /^##[ \t]+Success looks like[ \t]*$/
580
+ const NUMBERED_RECORD = /^\d{4}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
581
+ /**
582
+ * A kebab slug that does not open with an ordinal. The lookahead rejects a
583
+ * leading run of digits followed by a hyphen and nothing else, so a subject
584
+ * whose own name starts with a digit still passes.
585
+ */
586
+ const REFERENCE_NAME = /^(?!\d+-)[a-z0-9]+(-[a-z0-9]+)*\.md$/
587
+
588
+ /** The two fields every markdown file in a workspace carries. */
589
+ async function checkTeachFile(
590
+ dir: string,
591
+ slug: string,
592
+ file: string,
593
+ subject: string,
594
+ ): Promise<Finding[]> {
595
+ const frontmatter = parseFrontmatter(await readFile(join(dir, file), 'utf8'))
596
+
597
+ const missing = ['title', 'description'].filter(
598
+ (field) => !readField(frontmatter, field),
599
+ )
600
+
601
+ if (missing.length === 0) return []
602
+
603
+ return [
604
+ finding(
605
+ 'frontmatter-incomplete',
606
+ slug,
607
+ subject,
608
+ `carries no ${missing.join(' and no ')}.`,
609
+ ),
610
+ ]
611
+ }
612
+
613
+ /**
614
+ * One markdown subfolder of a workspace. `lessons/` and `assets/` are never
615
+ * reached, because a lesson is generated markup carrying no frontmatter and a
616
+ * walk over it would report every one as malformed.
617
+ */
618
+ async function checkTeachSubfolder(
619
+ dir: string,
620
+ slug: string,
621
+ folder: string,
622
+ name: RegExp,
623
+ message: string,
624
+ ): Promise<Finding[]> {
625
+ const path = join(dir, folder)
626
+
627
+ // Tested as a directory rather than for presence. Every other walk in this
628
+ // module takes its path from `listFolders`, and this one is built from a
629
+ // fixed name, so a workspace holding a plain file called `reference` would
630
+ // reach `readdir` and take the whole run down with `ENOTDIR`.
631
+ if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) return []
632
+
633
+ const files = await listMarkdown(path)
634
+
635
+ const malformed = files
636
+ .filter((file) => !name.test(file))
637
+ .map((file) =>
638
+ finding('name-malformed', slug, `${folder}/${file}`, message),
639
+ )
640
+
641
+ const perFile = await Promise.all(
642
+ files.map((file) => checkTeachFile(path, slug, file, `${folder}/${file}`)),
643
+ )
644
+
645
+ return [...malformed, ...perFile.flat()]
646
+ }
647
+
648
+ async function checkWorkspace(dir: string, slug: string): Promise<Finding[]> {
649
+ const findings: Finding[] = []
650
+
651
+ if (!WORKSPACE_NAME.test(slug)) {
652
+ findings.push(
653
+ finding(
654
+ 'name-malformed',
655
+ slug,
656
+ slug,
657
+ 'is not named NN-<topic> with a two-digit ordinal, so a listing sorts alphabetically rather than by when each workspace opened.',
658
+ ),
659
+ )
660
+ }
661
+
662
+ const files = await listMarkdown(dir)
663
+
664
+ if (!files.includes(TEACH_MISSION)) {
665
+ findings.push(
666
+ finding(
667
+ 'index-missing',
668
+ slug,
669
+ TEACH_MISSION,
670
+ 'is absent, so the workspace states no subject and no success to finish against.',
671
+ ),
672
+ )
673
+ }
674
+
675
+ for (const required of [TEACH_RESOURCES, TEACH_GLOSSARY]) {
676
+ if (!files.includes(required)) {
677
+ findings.push(
678
+ finding(
679
+ 'section-missing',
680
+ slug,
681
+ required,
682
+ 'is required and the workspace carries no such file.',
683
+ ),
684
+ )
685
+ }
686
+ }
687
+
688
+ const perFile = await Promise.all(
689
+ files.map((file) => checkTeachFile(dir, slug, file, file)),
690
+ )
691
+ findings.push(...perFile.flat())
692
+
693
+ if (files.includes(TEACH_MISSION)) {
694
+ const text = await readFile(join(dir, TEACH_MISSION), 'utf8')
695
+
696
+ if (!hasOpeningDate(parseFrontmatter(text)?.raw ?? '')) {
697
+ findings.push(
698
+ finding(
699
+ 'date-malformed',
700
+ slug,
701
+ TEACH_MISSION,
702
+ 'carries no date field as YYYY-MM-DD, so the workspace states no opening day.',
703
+ ),
704
+ )
705
+ }
706
+
707
+ if (
708
+ !linesOutsideFences(text).some((line) => TEACH_SUCCESS.test(line.trim()))
709
+ ) {
710
+ findings.push(
711
+ finding(
712
+ 'section-missing',
713
+ slug,
714
+ '## Success looks like',
715
+ 'is absent, so the mission names no observable thing the learner will be able to do.',
716
+ ),
717
+ )
718
+ }
719
+ }
720
+
721
+ return [
722
+ ...findings,
723
+ ...(await checkTeachSubfolder(
724
+ dir,
725
+ slug,
726
+ TEACH_REFERENCE,
727
+ REFERENCE_NAME,
728
+ 'is not named <slug>.md as a kebab slug opening with no ordinal, so a page looked up rather than worked through implies an order no reader follows.',
729
+ )),
730
+ ...(await checkTeachSubfolder(
731
+ dir,
732
+ slug,
733
+ TEACH_RECORDS,
734
+ NUMBERED_RECORD,
735
+ 'is not numbered NNNN-<slug>.md, so the records carry no read order.',
736
+ )),
737
+ ]
738
+ }
739
+
569
740
  const MEMORY_INDEX = 'index.md'
570
741
  const MEMORY_FIELDS = ['title', 'description', 'category'] as const
571
742
 
@@ -872,6 +1043,22 @@ export function checkStandard(name: string, text: string): Finding[] {
872
1043
  return [...findings, ...checkStandardName(name, scope.statement)]
873
1044
  }
874
1045
 
1046
+ /**
1047
+ * The three kinds whose records are folders rather than files. Keyed by kind so
1048
+ * a seventh arrives as an entry here and the compiler names the walk it owes,
1049
+ * where a ternary chain would silently fall through to whichever branch is last.
1050
+ */
1051
+ const FOLDER_CHECK: Readonly<
1052
+ Record<
1053
+ Exclude<RecordKind, 'plans' | 'memory' | 'standards'>,
1054
+ (dir: string, slug: string) => Promise<Finding[]>
1055
+ >
1056
+ > = {
1057
+ groundwork: checkTrack,
1058
+ intake: checkDump,
1059
+ teach: checkWorkspace,
1060
+ }
1061
+
875
1062
  function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
876
1063
  return { ok: false, reason, message }
877
1064
  }
@@ -939,7 +1126,7 @@ export async function validateRecords(
939
1126
  }
940
1127
 
941
1128
  const folders = await listFolders(dir)
942
- const check = kind === 'groundwork' ? checkTrack : checkDump
1129
+ const check = FOLDER_CHECK[kind]
943
1130
  const perFolder = await Promise.all(
944
1131
  folders.map((slug) => check(join(dir, slug), slug)),
945
1132
  )