# App fase 2 — SstData (woordenboek, correcties, geschiedenis) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** De gegevenslaag van de app: het woordenboek (bestand, correctieronde, waarschuwingen, voorrangslijst) en de transcriptiegeschiedenis (opslag, zoeken, groeperen), allemaal los van de interface en volledig getest. **Architecture:** Eén nieuw target `SstData` dat alleen van `SstCore` afhangt (voor `AppliedCorrection`, `CorrectionResult`, `TextCorrecting`, `DictationResult`). Pure logica (`DictionaryFile`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistorySearch`, `HistoryGrouping`) is hardware- en UI-vrij; bestandstoegang zit in `DictionaryStore` en `HistoryStore`. Woordkennis zit achter het protocol `WordKnowledge` zodat tests deterministisch zijn. **Tech Stack:** Swift 6.4 (language mode 5), Swift Testing, Foundation, NaturalLanguage, AppKit (spellingcontrole). **Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "Woordenboek — het ontwerp in detail", "Geschiedenis") **Voorwaarde:** fase 1 (`2026-10-01-app-1-engine-hooks.md`) is afgerond: `AppliedCorrection`, `CorrectionResult`, `TextCorrecting` en `DictationResult` bestaan in `SstCore`. ## Global Constraints - Dictionary-bestand: UTF-8, één invoer per regel; `#` = opmerking; `gehoord => schrijf` = correctiepaar; een regel zonder `=>` is een term. Opmerkingen, lege regels en volgorde blijven bij opslaan behouden; onleesbare regels blijven staan (`invalid`). - Correctie: hele woorden, hoofdletter-onafhankelijk, één doorgang van links naar rechts, langste overeenkomst eerst (gelijke lengte: eerste in het bestand), vervangen tekst wordt niet opnieuw gescand, vervanging is letterlijk. Delen van een bron worden verbonden door nul of meer spaties/koppeltekens (`[\s\-‐‑–]*`). Woordgrenzen: niet direct naast een letter of cijfer (`\p{L}`, `\p{N}`). - Een term van één woord dat een gewoon woord is (spellingcontrole of Engelse woordenlijst) krijgt géén automatische hoofdletterregel (wel voorrang bij het model). - Voorrangslijst: eerst termen (bestandsvolgorde), dan unieke rechterkanten van paren; maximaal 24 woorden. - Geschiedenis: JSON `{ "version": 1, "entries": [...] }`, ISO-8601 datums, nieuwste eerst, atomisch schrijven met 300 ms debounce; beschadigd bestand wordt hernoemd naar `history.corrupt-.json`. - Zoeken: hoofdletter- en diakriet-ongevoelig; alle zoekwoorden moeten voorkomen in de uiteindelijke of oorspronkelijke tekst. - Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). ## Bestandsstructuur ``` Package.swift (wijzig) target SstData + SstDataTests Sources/SstData/WordKnowledge.swift (nieuw) WordKnowledge, EntryKey, CommonWords, SystemWordKnowledge Sources/SstData/DictionaryFile.swift (nieuw) DictionaryEntry, DictionaryLine, DictionaryFile Sources/SstData/CorrectionEngine.swift (nieuw) Sources/SstData/EntryWarnings.swift (nieuw) Sources/SstData/VocabularyBuilder.swift (nieuw) Sources/SstData/History.swift (nieuw) HistoryEntry, HistoryStore, HistorySearch, HistoryGrouping Sources/SstData/DictionaryStore.swift (nieuw) DictionaryStore, DictionaryCorrector Tests/SstDataTests/Fakes.swift, DictionaryFileTests.swift, SystemWordKnowledgeTests.swift, CorrectionEngineTests.swift, EntryWarningsTests.swift, VocabularyBuilderTests.swift, HistoryTests.swift, DictionaryStoreTests.swift ``` --- ### Task 1: Target, woordkennis en `DictionaryFile` **Files:** - Modify: `Package.swift` - Create: `Sources/SstData/WordKnowledge.swift`, `Sources/SstData/DictionaryFile.swift` - Test: `Tests/SstDataTests/Fakes.swift`, `Tests/SstDataTests/DictionaryFileTests.swift`, `Tests/SstDataTests/SystemWordKnowledgeTests.swift` **Interfaces:** - Produces (gebruikt door Task 2–5 en de latere fasen): - `protocol WordKnowledge { func isWord(_:) -> Bool; func isFunctionWord(_:) -> Bool }` - `final class SystemWordKnowledge: WordKnowledge` (`init()`) - `enum EntryKey { static func parts(_:) -> [String]; static func key(_:) -> String }` (intern) - `struct DictionaryEntry: Identifiable, Equatable` met `id`, `kind: Kind` (`.term(String)` | `.correction(heard:wrote:)`), `source`, `wrote` - `enum DictionaryLine { entry, comment, blank, invalid }` - `struct DictionaryFile` met `init(parsing:)`, `serialized()`, `entries`, `append(_:) -> UUID`, `update(id:kind:)`, `remove(id:)` - [ ] **Step 1: Targets toevoegen** In `Package.swift`, voeg bij `targets` toe (naast de bestaande regels): ```swift .target(name: "SstData", dependencies: ["SstCore"]), ``` en bij de testtargets: ```swift .testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]), ``` - [ ] **Step 2: Schrijf de falende tests** `Tests/SstDataTests/Fakes.swift`: ```swift import Foundation @testable import SstData /// Deterministic stand-in for the macOS spell checker. struct FakeKnowledge: WordKnowledge { var words: Set = ["cloud", "code", "some", "thing", "something", "reef", "slack", "in", "de", "the", "of"] var functionWords: Set = ["in", "de", "the", "of"] func isWord(_ word: String) -> Bool { words.contains(word.lowercased()) } func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word.lowercased()) } } func term(_ text: String) -> DictionaryEntry { DictionaryEntry(kind: .term(text)) } func pair(_ heard: String, _ wrote: String) -> DictionaryEntry { DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)) } ``` `Tests/SstDataTests/DictionaryFileTests.swift`: ```swift import Testing @testable import SstData struct DictionaryFileTests { let sample = "# Opmerking\nAnthropic\n\ncloud code => Claude Code\n" @Test func parsesTermsPairsCommentsAndBlanks() { let file = DictionaryFile(parsing: sample) #expect(file.lines.count == 4) #expect(file.entries == [term("Anthropic"), pair("cloud code", "Claude Code")]) #expect(file.lines[0] == .comment("# Opmerking")) #expect(file.lines[2] == .blank) } @Test func roundTripKeepsCommentsBlankLinesAndOrder() { #expect(DictionaryFile(parsing: sample).serialized() == sample) } @Test func normalisesSpacingAroundTheArrow() { #expect(DictionaryFile(parsing: "a=>b\n").serialized() == "a => b\n") #expect(DictionaryFile(parsing: " cloud code => Claude Code \n").entries == [pair("cloud code", "Claude Code")]) } @Test func unreadableLinesAreKeptAsInvalid() { let file = DictionaryFile(parsing: "oops =>\n=> nothing\nok\n") #expect(file.lines[0] == .invalid("oops =>")) #expect(file.lines[1] == .invalid("=> nothing")) #expect(file.serialized() == "oops =>\n=> nothing\nok\n") } @Test func handlesWindowsLineEndingsAndMissingFinalNewline() { let file = DictionaryFile(parsing: "a\r\nb => c") #expect(file.entries == [term("a"), pair("b", "c")]) #expect(file.serialized() == "a\nb => c\n") } @Test func emptyTextIsAValidEmptyFile() { #expect(DictionaryFile(parsing: "").entries.isEmpty) #expect(DictionaryFile(parsing: "").serialized() == "") } @Test func appendUpdateAndRemove() { var file = DictionaryFile(parsing: "# c\n") let id = file.append(.term("Vercel")) #expect(file.entries == [term("Vercel")]) file.update(id: id, kind: .correction(heard: "versel", wrote: "Vercel")) #expect(file.entries == [pair("versel", "Vercel")]) file.remove(id: id) #expect(file.entries.isEmpty) #expect(file.serialized() == "# c\n") } } ``` `Tests/SstDataTests/SystemWordKnowledgeTests.swift`: ```swift import Testing @testable import SstData struct SystemWordKnowledgeTests { let knowledge = SystemWordKnowledge() @Test func recognisesEnglishAndDutchWords() { #expect(knowledge.isWord("cloud")) #expect(knowledge.isWord("Cloud")) #expect(knowledge.isWord("vandaag")) } @Test func doesNotTreatProductNamesAsWords() { #expect(!knowledge.isWord("vercel")) #expect(!knowledge.isWord("supabase")) #expect(!knowledge.isWord("anthropic")) } @Test func functionWordsComeFromTheBuiltInList() { #expect(knowledge.isFunctionWord("de")) #expect(knowledge.isFunctionWord("The")) #expect(!knowledge.isFunctionWord("vercel")) } } ``` - [ ] **Step 3: Draai de tests, moeten falen** Run: `swift test --filter SstDataTests` Expected: compilatiefouten (`cannot find 'DictionaryFile'`, `cannot find type 'WordKnowledge'`). - [ ] **Step 4: Implementatie** `Sources/SstData/WordKnowledge.swift`: ```swift import AppKit import Foundation import NaturalLanguage public protocol WordKnowledge { /// A common word in Dutch or English (case-insensitive). Rare words, names and product names are not. func isWord(_ word: String) -> Bool /// A very frequent function word ("in", "de", "the", "of"). func isFunctionWord(_ word: String) -> Bool } /// Splitting and normalising the left-hand side of an entry, shared by the engine and the warnings. enum EntryKey { static func parts(_ source: String) -> [String] { source.split(whereSeparator: { $0.isWhitespace || "-‐‑–".contains($0) }).map(String.init) } /// Spacing, hyphens and case do not matter: "Cloud-Code", "cloud code" and "cloudcode" are the same source. static func key(_ source: String) -> String { parts(source).joined().lowercased() } } /// The most frequent Dutch and English function words. enum CommonWords { static let english: Set = [ "a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because", "been", "but", "by", "can", "could", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "here", "him", "his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "more", "most", "my", "no", "not", "now", "of", "on", "one", "only", "or", "other", "our", "out", "over", "she", "so", "some", "such", "than", "that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "to", "up", "us", "very", "was", "we", "were", "what", "when", "which", "who", "will", "with", "would", "you", "your", ] static let dutch: Set = [ "aan", "al", "als", "ben", "bij", "dan", "dat", "de", "den", "der", "deze", "die", "dit", "doen", "door", "dus", "een", "en", "er", "ga", "had", "heb", "heeft", "hem", "het", "hier", "hij", "hoe", "ik", "in", "is", "ja", "je", "jij", "kan", "kon", "maar", "me", "mij", "mijn", "moet", "na", "naar", "niet", "nog", "nu", "of", "om", "ons", "onze", "ook", "op", "over", "te", "ten", "ter", "toch", "toen", "tot", "u", "uit", "van", "veel", "voor", "was", "wat", "we", "wel", "werd", "wij", "wil", "worden", "wordt", "zal", "ze", "zei", "zich", "zij", "zijn", "zo", "zou", ] static let all: Set = english.union(dutch) } /// English: the on-device word list of `NaturalLanguage` (common words only, so "cloud" and "reef" are in, "anthropic" and "vercel" are not). /// Dutch has no such list, so the spell checker decides. Function words come from the built-in list. public final class SystemWordKnowledge: WordKnowledge, @unchecked Sendable { private let englishWords = NLEmbedding.wordEmbedding(for: .english) public init() {} public func isWord(_ word: String) -> Bool { let lowered = word.lowercased() guard !lowered.isEmpty else { return false } if let englishWords { if englishWords.contains(lowered) { return true } } else if spellChecks(lowered, language: "en") { return true } return spellChecks(lowered, language: "nl") } public func isFunctionWord(_ word: String) -> Bool { CommonWords.all.contains(word.lowercased()) } private func spellChecks(_ word: String, language: String) -> Bool { NSSpellChecker.shared.checkSpelling( of: word, startingAt: 0, language: language, wrap: false, inSpellDocumentWithTag: 0, wordCount: nil).location == NSNotFound } } ``` `Sources/SstData/DictionaryFile.swift`: ```swift import Foundation public struct DictionaryEntry: Identifiable, Equatable, Sendable { public enum Kind: Equatable, Sendable { case term(String) case correction(heard: String, wrote: String) } public let id: UUID public var kind: Kind public init(id: UUID = UUID(), kind: Kind) { self.id = id self.kind = kind } /// Equality ignores `id`: two entries with the same content are the same entry. public static func == (lhs: DictionaryEntry, rhs: DictionaryEntry) -> Bool { lhs.kind == rhs.kind } /// The text the correction pass listens for (a term listens for itself). public var source: String { switch kind { case .term(let term): term case .correction(let heard, _): heard } } /// The text that ends up in the transcript. public var wrote: String { switch kind { case .term(let term): term case .correction(_, let wrote): wrote } } } public enum DictionaryLine: Equatable, Sendable { case entry(DictionaryEntry) case comment(String) case blank case invalid(String) } /// The dictionary as a list of lines, so comments, blank lines and order survive an edit. public struct DictionaryFile: Equatable, Sendable { public var lines: [DictionaryLine] public init(lines: [DictionaryLine] = []) { self.lines = lines } public init(parsing text: String) { var raw = text.replacingOccurrences(of: "\r\n", with: "\n") .split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) if raw.last == "" { raw.removeLast() } lines = raw.map(Self.parseLine) } static func parseLine(_ line: String) -> DictionaryLine { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { return .blank } if trimmed.hasPrefix("#") { return .comment(line) } if let arrow = trimmed.range(of: "=>") { let heard = trimmed[.. String { let out = lines.map { line -> String in switch line { case .entry(let entry): switch entry.kind { case .term(let term): return term case .correction(let heard, let wrote): return "\(heard) => \(wrote)" } case .comment(let text), .invalid(let text): return text case .blank: return "" } } return out.isEmpty ? "" : out.joined(separator: "\n") + "\n" } public var entries: [DictionaryEntry] { lines.compactMap { line in if case .entry(let entry) = line { return entry } return nil } } @discardableResult public mutating func append(_ kind: DictionaryEntry.Kind) -> UUID { let entry = DictionaryEntry(kind: kind) lines.append(.entry(entry)) return entry.id } public mutating func update(id: UUID, kind: DictionaryEntry.Kind) { guard let index = lines.firstIndex(where: { if case .entry(let e) = $0 { return e.id == id } else { return false } }) else { return } lines[index] = .entry(DictionaryEntry(id: id, kind: kind)) } public mutating func remove(id: UUID) { lines.removeAll { if case .entry(let e) = $0 { return e.id == id } else { return false } } } } ``` - [ ] **Step 5: Draai de tests** Run: `swift test --filter SstDataTests` Expected: alle tests PASS (7 `DictionaryFile`, 3 `SystemWordKnowledge`). > De Engelse woordenlijst komt uit `NLEmbedding.wordEmbedding(for: .english)`. Is die op de machine niet beschikbaar, dan valt `isWord` terug op de Engelse spellingcontrole; de tests verwachten dan dat "anthropic" óók een woord is en kunnen falen. Meld dat als BLOCKED met de uitvoer van `swift test`, wijzig de test niet stilletjes. - [ ] **Step 6: Commit** ```bash git add Package.swift Sources/SstData Tests/SstDataTests git commit -m "feat: add SstData with DictionaryFile and word knowledge" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 2: `CorrectionEngine` **Files:** - Create: `Sources/SstData/CorrectionEngine.swift` - Test: `Tests/SstDataTests/CorrectionEngineTests.swift` **Interfaces:** - Consumes: Task 1 (`DictionaryEntry`, `WordKnowledge`, `EntryKey`) en `SstCore.TextCorrecting`/`CorrectionResult`/`AppliedCorrection`. - Produces: `struct CorrectionEngine: TextCorrecting` met `init(entries: [DictionaryEntry], knowledge: WordKnowledge)` en `correct(_:) -> CorrectionResult`. - [ ] **Step 1: Schrijf de falende tests** `Tests/SstDataTests/CorrectionEngineTests.swift`: ```swift import Foundation import Testing import SstCore @testable import SstData struct CorrectionEngineTests { let knowledge = FakeKnowledge() func engine(_ entries: [DictionaryEntry]) -> CorrectionEngine { CorrectionEngine(entries: entries, knowledge: knowledge) } @Test func replacesAPairAndReportsIt() { let result = engine([pair("cloud code", "Claude Code")]).correct("ik gebruik cloud code vandaag") #expect(result.text == "ik gebruik Claude Code vandaag") #expect(result.corrections.count == 1) let c = result.corrections[0] #expect(c.heard == "cloud code" && c.wrote == "Claude Code" && c.rule == "cloud code") #expect((result.text as NSString).substring(with: NSRange(location: c.start, length: c.length)) == "Claude Code") } @Test func findsGluedHyphenatedAndSpacedVariants() { let e = engine([pair("cloud code", "Claude Code")]) for variant in ["CloudCode", "Cloud-Code", "CLOUD CODE", "cloud\u{2011}code", "cloud code"] { #expect(e.correct(variant).text == "Claude Code", "variant \(variant)") } } @Test func neverTouchesRealWordsThatMerelyStartTheSame() { let e = engine([pair("cloud code", "Claude Code")]) #expect(e.correct("Cloudflare is handig").text == "Cloudflare is handig") #expect(e.correct("cloud codex").text == "cloud codex") #expect(e.correct("de cloud en de code").text == "de cloud en de code") #expect(e.correct("the cloud").corrections.isEmpty) } @Test func respectsPunctuationBoundaries() { let e = engine([pair("cloud code", "Claude Code")]) #expect(e.correct("(cloud code)").text == "(Claude Code)") #expect(e.correct("cloud code.").text == "Claude Code.") #expect(e.correct("Cloud Code, Cloud Code").text == "Claude Code, Claude Code") } @Test func longestMatchWins() { let e = engine([pair("cloud code", "Claude Code"), pair("cloud code review", "Claude Code Review")]) #expect(e.correct("cloud code review nu").text == "Claude Code Review nu") #expect(e.correct("cloud-code-review").text == "Claude Code Review") } @Test func replacedTextIsNeverRescanned() { let e = engine([pair("a", "b"), pair("b", "c")]) #expect(e.correct("a b").text == "b c") } @Test func termsFixCasingAndSpacing() { let e = engine([term("Anthropic"), term("Claude Code")]) #expect(e.correct("anthropic ANTHROPIC").text == "Anthropic Anthropic") #expect(e.correct("claudecode en claude-code").text == "Claude Code en Claude Code") } @Test func termAlreadyRightIsNotReportedAsACorrection() { let result = engine([term("Anthropic")]).correct("Anthropic bouwt Claude") #expect(result.text == "Anthropic bouwt Claude") #expect(result.corrections.isEmpty) } @Test func singleWordTermThatIsARealWordGetsNoCasingRule() { let e = engine([term("Reef"), term("Slack")]) #expect(e.correct("het reef en slack").text == "het reef en slack") } @Test func firstEntryWinsOnDuplicateSources() { let e = engine([pair("cloud code", "Claude Code"), pair("Cloud-Code", "Other")]) #expect(e.correct("cloudcode").text == "Claude Code") } @Test func replacementIsLiteral() { let e = engine([pair("price", "US$1 \\0 $1")]) #expect(e.correct("the price").text == "the US$1 \\0 $1") } @Test func handlesDiacritics() { let e = engine([term("Café")]) #expect(e.correct("een CAFÉ om de hoek").text == "een Café om de hoek") } @Test func offsetsAreRelativeToTheCorrectedText() { let result = engine([pair("a", "xxx"), pair("b", "y")]).correct("a b a") #expect(result.text == "xxx y xxx") #expect(result.corrections.map(\.start) == [0, 4, 6]) #expect(result.corrections.map(\.length) == [3, 1, 3]) } @Test func emptyDictionaryAndEmptyTextAreNoOps() { #expect(engine([]).correct("hallo").text == "hallo") #expect(engine([pair("a", "b")]).correct("").text == "") } } ``` - [ ] **Step 2: Draai de tests, moeten falen** Run: `swift test --filter CorrectionEngineTests` Expected: compilatiefout `cannot find 'CorrectionEngine' in scope`. - [ ] **Step 3: Implementatie** `Sources/SstData/CorrectionEngine.swift`: ```swift import Foundation import SstCore /// Whole-word, case-insensitive, single-pass replacement. Longest match wins, replaced text is never rescanned. public struct CorrectionEngine: TextCorrecting { private struct Rule { let source: String let wrote: String } private let rules: [Rule] private let regex: NSRegularExpression? public init(entries: [DictionaryEntry], knowledge: WordKnowledge) { var seen = Set() var built: [(rule: Rule, order: Int)] = [] for (order, entry) in entries.enumerated() { let source = entry.source.trimmingCharacters(in: .whitespacesAndNewlines) let key = EntryKey.key(source) guard !key.isEmpty, !seen.contains(key) else { continue } // A single-word term that is also a real word only biases the model; capitalising it everywhere would corrupt normal text. if case .term(let term) = entry.kind, EntryKey.parts(term).count == 1, knowledge.isWord(term) { continue } seen.insert(key) built.append((Rule(source: source, wrote: entry.wrote), order)) } let ordered = built.sorted { a, b in let la = a.rule.source.filter(\.isLetter).count let lb = b.rule.source.filter(\.isLetter).count return la != lb ? la > lb : a.order < b.order }.map(\.rule) rules = ordered if ordered.isEmpty { regex = nil } else { let alternatives = ordered.map { "(\(Self.pattern($0.source)))" }.joined(separator: "|") regex = try? NSRegularExpression( pattern: "(? String { EntryKey.parts(source) .map { NSRegularExpression.escapedPattern(for: $0) } .joined(separator: "[\\s\\-‐‑–]*") } public func correct(_ text: String) -> CorrectionResult { guard let regex, !text.isEmpty else { return CorrectionResult(text: text, corrections: []) } let ns = text as NSString var output = "" var outputLength = 0 var last = 0 var applied: [AppliedCorrection] = [] for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { let gap = ns.substring(with: NSRange(location: last, length: match.range.location - last)) output += gap outputLength += gap.utf16.count let heard = ns.substring(with: match.range) let group = (1.." ``` --- ### Task 3: `EntryWarnings` en `VocabularyBuilder` **Files:** - Create: `Sources/SstData/EntryWarnings.swift`, `Sources/SstData/VocabularyBuilder.swift` - Test: `Tests/SstDataTests/EntryWarningsTests.swift`, `Tests/SstDataTests/VocabularyBuilderTests.swift` **Interfaces:** - Consumes: Task 1. - Produces: - `struct EntryWarning { level: Level (.note < .warning < .error); message: String }` - `EntryWarnings.check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning]` (meest ernstig eerst; `others` = alle andere invoeren) - `VocabularyBuilder.build(from: [DictionaryEntry], maxTerms: Int = 24) -> [String]` - [ ] **Step 1: Schrijf de falende tests** `Tests/SstDataTests/EntryWarningsTests.swift`: ```swift import Testing @testable import SstData struct EntryWarningsTests { let knowledge = FakeKnowledge() func check(_ kind: DictionaryEntry.Kind, others: [DictionaryEntry] = []) -> [EntryWarning] { EntryWarnings.check(kind, among: others, knowledge: knowledge) } @Test func singleCommonWordIsAWarning() { let w = check(.correction(heard: "cloud", wrote: "Claude")) #expect(w.contains { $0.level == .warning && $0.message.contains("cloud") }) } @Test func veryShortSourceIsAWarning() { #expect(check(.correction(heard: "ab", wrote: "AB")).contains { $0.level == .warning }) } @Test func functionWordInsideAPhraseIsAWarning() { #expect(check(.correction(heard: "in de", wrote: "indeed")).contains { $0.level == .warning }) } @Test func gluedFormThatIsARealWordIsAWarning() { let w = check(.correction(heard: "some thing", wrote: "Something")) #expect(w.contains { $0.level == .warning && $0.message.contains("something") }) } @Test func phraseOfCommonWordsOnlyGetsANote() { let w = check(.correction(heard: "cloud code", wrote: "Claude Code")) #expect(w.count == 1) #expect(w[0].level == .note) } @Test func distinctiveEntriesGetNoWarnings() { #expect(check(.correction(heard: "versel", wrote: "Vercel")).isEmpty) #expect(check(.term("Supabase")).isEmpty) } @Test func duplicateSourceIsAnErrorEvenWithDifferentSpelling() { let others = [pair("cloud code", "Claude Code")] let w = check(.correction(heard: "Cloud-Code", wrote: "X"), others: others) #expect(w.first?.level == .error) } @Test func termThatIsARealWordGetsANoteAboutBiasOnly() { let w = check(.term("Reef")) #expect(w.count == 1) #expect(w[0].level == .note) } @Test func emptySourceIsAnError() { #expect(check(.term(" ")).first?.level == .error) } @Test func warningsAreSortedMostSevereFirst() { let w = check(.correction(heard: "cloud", wrote: "Claude"), others: [pair("cloud", "x")]) #expect(w.map(\.level) == w.map(\.level).sorted(by: >)) } } ``` `Tests/SstDataTests/VocabularyBuilderTests.swift`: ```swift import Testing @testable import SstData struct VocabularyBuilderTests { @Test func termsComeFirstThenCorrectionTargetsWithoutDuplicates() { let entries = [pair("cloud code", "Claude Code"), term("Anthropic"), term("anthropic"), pair("versel", "Vercel"), term("Supabase")] #expect(VocabularyBuilder.build(from: entries) == ["Anthropic", "Supabase", "Claude Code", "Vercel"]) } @Test func listIsCapped() { let entries = (1...40).map { term("Woord\($0)") } #expect(VocabularyBuilder.build(from: entries).count == 24) #expect(VocabularyBuilder.build(from: entries, maxTerms: 5).count == 5) } @Test func emptyDictionaryGivesNoVocabulary() { #expect(VocabularyBuilder.build(from: []).isEmpty) } } ``` - [ ] **Step 2: Draai de tests, moeten falen** Run: `swift test --filter "EntryWarningsTests|VocabularyBuilderTests"` Expected: compilatiefouten (`cannot find 'EntryWarnings'`, `cannot find 'VocabularyBuilder'`). - [ ] **Step 3: Implementatie** `Sources/SstData/EntryWarnings.swift`: ```swift import Foundation public struct EntryWarning: Equatable, Sendable { public enum Level: Int, Comparable, Sendable { case note = 0, warning, error public static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue } } public let level: Level public let message: String public init(level: Level, message: String) { self.level = level self.message = message } } /// Flags dictionary entries that are likely to corrupt normal text. public enum EntryWarnings { /// - Parameter others: all entries except the one being checked. public static func check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning] { let source: String let isCorrection: Bool switch kind { case .term(let term): (source, isCorrection) = (term, false) case .correction(let heard, _): (source, isCorrection) = (heard, true) } let parts = EntryKey.parts(source) guard !parts.isEmpty else { return [EntryWarning(level: .error, message: "Leeg: vul een woord in.")] } var result: [EntryWarning] = [] if others.contains(where: { EntryKey.key($0.source) == EntryKey.key(source) }) { result.append(EntryWarning(level: .error, message: "Dubbele invoer: deze bron staat er al, de eerste telt.")) } if !isCorrection { if parts.count == 1, knowledge.isWord(source) { result.append(EntryWarning(level: .note, message: "Gewoon woord: krijgt alleen voorrang bij het model, geen automatische hoofdletterregel.")) } return result.sorted { $0.level > $1.level } } if parts.count == 1, knowledge.isWord(parts[0]) { result.append(EntryWarning(level: .warning, message: "'\(parts[0])' is een gewoon woord en wordt overal in je tekst vervangen.")) } if source.filter(\.isLetter).count < 3 { result.append(EntryWarning(level: .warning, message: "Erg kort (minder dan 3 letters): veel kans op ongewenste vervangingen.")) } if parts.count > 1 { var flagged = false if let common = parts.first(where: { knowledge.isFunctionWord($0) }) { result.append(EntryWarning(level: .warning, message: "Bevat het veelvoorkomende woord '\(common)'; dit kan gewone zinnen raken.")) flagged = true } let glued = parts.joined() if knowledge.isWord(glued) { result.append(EntryWarning(level: .warning, message: "Aaneengeschreven ('\(glued)') is een bestaand woord en wordt ook vervangen.")) flagged = true } if !flagged, parts.allSatisfy({ knowledge.isWord($0) }) { result.append(EntryWarning(level: .note, message: "Alle delen zijn gewone woorden: alleen de hele combinatie wordt vervangen.")) } } return result.sorted { $0.level > $1.level } } } ``` `Sources/SstData/VocabularyBuilder.swift`: ```swift import Foundation /// The short word list handed to the speech engine as context. public enum VocabularyBuilder { /// Terms first (file order), then the unique right-hand sides of correction pairs; at most `maxTerms` words. public static func build(from entries: [DictionaryEntry], maxTerms: Int = 24) -> [String] { var out: [String] = [] var seen = Set() func add(_ word: String) { let key = word.lowercased() guard !word.isEmpty, !seen.contains(key) else { return } seen.insert(key) out.append(word) } for entry in entries { if case .term(let term) = entry.kind { add(term) } } for entry in entries { if case .correction(_, let wrote) = entry.kind { add(wrote) } } return Array(out.prefix(maxTerms)) } } ``` - [ ] **Step 4: Draai de tests** Run: `swift test --filter "EntryWarningsTests|VocabularyBuilderTests"` Expected: 10 + 3 tests PASS. - [ ] **Step 5: Commit** ```bash git add Sources/SstData Tests/SstDataTests git commit -m "feat: add EntryWarnings and VocabularyBuilder" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 4: Geschiedenis — opslag, zoeken, groeperen **Files:** - Create: `Sources/SstData/History.swift` - Test: `Tests/SstDataTests/HistoryTests.swift` **Interfaces:** - Consumes: `SstCore.DictationResult`, `SstCore.AppliedCorrection`. - Produces: - `struct HistoryEntry: Codable, Equatable, Identifiable` (`id`, `date`, `duration`, `rawText`, `finalText`, `corrections`; `init(result:)`) - `@MainActor final class HistoryStore` met `init(fileURL:)`, `entries` (nieuwste eerst), `load() -> LoadOutcome` (`.empty | .loaded | .recoveredCorrupt(backup:)`), `add(_:)`, `remove(id:)`, `removeAll()`, `flush()`, `onChange`, `lastError` - `HistorySearch.filter(_:query:) -> [HistoryEntry]` - `HistoryGrouping.byDay(_:calendar:) -> [HistoryDay]` (`HistoryDay`: `day: Date`, `entries`) - [ ] **Step 1: Schrijf de falende tests** `Tests/SstDataTests/HistoryTests.swift`: ```swift import Foundation import Testing import SstCore @testable import SstData @MainActor struct HistoryStoreTests { func tempFile() -> URL { FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json") } func entry(_ text: String, at seconds: TimeInterval) -> HistoryEntry { HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + seconds), duration: 2, rawText: text, finalText: text) } @Test func newestEntryComesFirstAndSurvivesARoundTrip() { let url = tempFile() let store = HistoryStore(fileURL: url) store.add(entry("eerste", at: 0)) store.add(HistoryEntry( date: Date(timeIntervalSince1970: 1_700_000_100), duration: 3, rawText: "cloud code", finalText: "Claude Code", corrections: [AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 0, length: 11)])) store.flush() let reloaded = HistoryStore(fileURL: url) #expect(reloaded.load() == .loaded) #expect(reloaded.entries.map(\.finalText) == ["Claude Code", "eerste"]) #expect(reloaded.entries == store.entries) } @Test func missingFileIsEmpty() { let store = HistoryStore(fileURL: tempFile()) #expect(store.load() == .empty) #expect(store.entries.isEmpty) } @Test func removeAndRemoveAll() { let store = HistoryStore(fileURL: tempFile()) let a = entry("a", at: 0), b = entry("b", at: 1) store.add(a); store.add(b) store.remove(id: a.id) #expect(store.entries.map(\.finalText) == ["b"]) store.removeAll() #expect(store.entries.isEmpty) } @Test func corruptFileIsMovedAsideAndStoreStartsFresh() throws { let url = tempFile() try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try "dit is geen json".write(to: url, atomically: true, encoding: .utf8) let store = HistoryStore(fileURL: url) guard case .recoveredCorrupt(let backup) = store.load() else { Issue.record("expected recoveredCorrupt"); return } #expect(FileManager.default.fileExists(atPath: backup.path)) #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(store.entries.isEmpty) } @Test func onChangeFiresForEdits() { let store = HistoryStore(fileURL: tempFile()) var calls = 0 store.onChange = { calls += 1 } store.add(entry("a", at: 0)) store.removeAll() #expect(calls == 2) } @Test func entryFromDictationResultKeepsEverything() { let result = DictationResult( date: Date(timeIntervalSince1970: 1_700_000_000), duration: 4.5, rawText: "raw", finalText: "final", corrections: [AppliedCorrection(heard: "a", wrote: "b", rule: "a", start: 0, length: 1)]) let e = HistoryEntry(result: result) #expect(e.rawText == "raw" && e.finalText == "final" && e.duration == 4.5 && e.corrections.count == 1) } } struct HistorySearchTests { let entries = [ HistoryEntry(date: .now, duration: 1, rawText: "ik ga naar het café", finalText: "Ik ga naar het Café"), HistoryEntry(date: .now, duration: 1, rawText: "cloud code is handig", finalText: "Claude Code is handig"), ] @Test func emptyQueryReturnsEverything() { #expect(HistorySearch.filter(entries, query: " ").count == 2) } @Test func ignoresCaseAndDiacritics() { #expect(HistorySearch.filter(entries, query: "CAFE").count == 1) #expect(HistorySearch.filter(entries, query: "café").count == 1) } @Test func allWordsMustMatchInAnyOrder() { #expect(HistorySearch.filter(entries, query: "handig claude").count == 1) #expect(HistorySearch.filter(entries, query: "handig café").isEmpty) } @Test func searchesTheOriginalTextToo() { #expect(HistorySearch.filter(entries, query: "cloud").count == 1) } } struct HistoryGroupingTests { @Test func groupsByDayKeepingOrder() { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "UTC")! func at(_ day: Int, _ hour: Int) -> Date { calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))! } let entries = [at(2, 15), at(2, 9), at(1, 20)].map { HistoryEntry(date: $0, duration: 1, rawText: "x", finalText: "x") } let days = HistoryGrouping.byDay(entries, calendar: calendar) #expect(days.count == 2) #expect(days[0].entries.count == 2) #expect(days[1].entries.count == 1) #expect(days[0].day > days[1].day) } } ``` - [ ] **Step 2: Draai de tests, moeten falen** Run: `swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests"` Expected: compilatiefout `cannot find 'HistoryStore' in scope`. - [ ] **Step 3: Implementatie** `Sources/SstData/History.swift`: ```swift import Foundation import SstCore public struct HistoryEntry: Codable, Equatable, Identifiable, Sendable { public var id: UUID public var date: Date public var duration: TimeInterval public var rawText: String public var finalText: String public var corrections: [AppliedCorrection] public init( id: UUID = UUID(), date: Date, duration: TimeInterval, rawText: String, finalText: String, corrections: [AppliedCorrection] = [] ) { self.id = id self.date = date self.duration = duration self.rawText = rawText self.finalText = finalText self.corrections = corrections } public init(result: DictationResult) { self.init( date: result.date, duration: result.duration, rawText: result.rawText, finalText: result.finalText, corrections: result.corrections) } } /// Newest first, stored as one JSON file; saves are debounced. @MainActor public final class HistoryStore { public enum LoadOutcome: Equatable { case empty case loaded case recoveredCorrupt(backup: URL) } private struct Document: Codable { var version: Int var entries: [HistoryEntry] } public private(set) var entries: [HistoryEntry] = [] public private(set) var lastError: String? public var onChange: (() -> Void)? private let fileURL: URL private var saveTask: Task? public init(fileURL: URL) { self.fileURL = fileURL } private static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return encoder } private static func makeDecoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder } @discardableResult public func load() -> LoadOutcome { guard let data = try? Data(contentsOf: fileURL) else { entries = [] return .empty } do { entries = try Self.makeDecoder().decode(Document.self, from: data).entries return .loaded } catch { let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json") try? FileManager.default.moveItem(at: fileURL, to: backup) entries = [] return .recoveredCorrupt(backup: backup) } } public func add(_ entry: HistoryEntry) { entries.insert(entry, at: 0) changed() } public func remove(id: UUID) { entries.removeAll { $0.id == id } changed() } public func removeAll() { entries = [] changed() } /// Writes immediately (also call this when the app quits). public func flush() { saveTask?.cancel() saveTask = nil do { try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) let data = try Self.makeEncoder().encode(Document(version: 1, entries: entries)) try data.write(to: fileURL, options: .atomic) lastError = nil } catch { lastError = error.localizedDescription } } private func changed() { onChange?() saveTask?.cancel() saveTask = Task { [weak self] in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } self?.flush() } } } public enum HistorySearch { static func fold(_ text: String) -> String { text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) } /// Every query word must occur in the final or the original text (case- and diacritic-insensitive). public static func filter(_ entries: [HistoryEntry], query: String) -> [HistoryEntry] { let words = fold(query).split(whereSeparator: \.isWhitespace).map(String.init) guard !words.isEmpty else { return entries } return entries.filter { entry in let haystack = fold(entry.finalText + " " + entry.rawText) return words.allSatisfy { haystack.contains($0) } } } } public struct HistoryDay: Identifiable, Equatable, Sendable { public let day: Date public let entries: [HistoryEntry] public var id: Date { day } } public enum HistoryGrouping { /// Groups newest-first entries by calendar day, keeping their order. public static func byDay(_ entries: [HistoryEntry], calendar: Calendar = .current) -> [HistoryDay] { var days: [HistoryDay] = [] for entry in entries { let start = calendar.startOfDay(for: entry.date) if let last = days.last, last.day == start { days[days.count - 1] = HistoryDay(day: start, entries: last.entries + [entry]) } else { days.append(HistoryDay(day: start, entries: [entry])) } } return days } } ``` - [ ] **Step 4: Draai de tests** Run: `swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests"` Expected: 6 + 4 + 1 tests PASS. - [ ] **Step 5: Commit** ```bash git add Sources/SstData Tests/SstDataTests git commit -m "feat: add history store, search and day grouping" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 5: `DictionaryStore` en `DictionaryCorrector` **Files:** - Create: `Sources/SstData/DictionaryStore.swift` - Test: `Tests/SstDataTests/DictionaryStoreTests.swift` **Interfaces:** - Consumes: Task 1 (`DictionaryFile`), Task 2 (`CorrectionEngine`). - Produces: - `@MainActor final class DictionaryStore` met `init(fileURL:)`, `fileURL`, `file`, `entries`, `load() throws`, `apply(_ mutate: (inout DictionaryFile) -> Void) throws`, `reloadIfChanged()`, `startWatching()`, `stopWatching()`, `onChange`, `static header` - `final class DictionaryCorrector: TextCorrecting` met `init(knowledge:)`, `update(entries:)`, `setEnabled(_:)` - [ ] **Step 1: Schrijf de falende tests** `Tests/SstDataTests/DictionaryStoreTests.swift`: ```swift import Foundation import Testing import SstCore @testable import SstData @MainActor struct DictionaryStoreTests { func tempFile() -> URL { FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/dictionary.txt") } @Test func loadCreatesAFileWithAHeaderWhenMissing() throws { let url = tempFile() let store = DictionaryStore(fileURL: url) try store.load() #expect(FileManager.default.fileExists(atPath: url.path)) #expect(store.entries.isEmpty) #expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# Sst woordenboek")) } @Test func applyWritesAtomicallyAndKeepsComments() throws { let url = tempFile() let store = DictionaryStore(fileURL: url) try store.load() try store.apply { $0.append(.correction(heard: "cloud code", wrote: "Claude Code")) } let text = try String(contentsOf: url, encoding: .utf8) #expect(text.hasPrefix("# Sst woordenboek")) #expect(text.hasSuffix("cloud code => Claude Code\n")) #expect(store.entries == [pair("cloud code", "Claude Code")]) } @Test func externalEditIsPickedUp() throws { let url = tempFile() let store = DictionaryStore(fileURL: url) try store.load() var changes = 0 store.onChange = { changes += 1 } try "Vercel\n".write(to: url, atomically: true, encoding: .utf8) store.reloadIfChanged() #expect(store.entries == [term("Vercel")]) #expect(changes == 1) store.reloadIfChanged() // unchanged text: no second notification #expect(changes == 1) } @Test func ourOwnWriteDoesNotTriggerAReload() throws { let store = DictionaryStore(fileURL: tempFile()) try store.load() try store.apply { $0.append(.term("Supabase")) } var changes = 0 store.onChange = { changes += 1 } store.reloadIfChanged() #expect(changes == 0) } @Test func watcherNoticesAnEditorSavingTheFile() async throws { let url = tempFile() let store = DictionaryStore(fileURL: url) try store.load() var changed = false store.onChange = { changed = true } store.startWatching() try "Anthropic\n".write(to: url, atomically: true, encoding: .utf8) for _ in 0..<40 where !changed { try await Task.sleep(for: .milliseconds(100)) } store.stopWatching() #expect(changed) #expect(store.entries == [term("Anthropic")]) } } struct DictionaryCorrectorTests { @Test func correctsWhenEnabledAndPassesThroughWhenDisabled() { let corrector = DictionaryCorrector(knowledge: FakeKnowledge()) corrector.update(entries: [pair("cloud code", "Claude Code")]) #expect(corrector.correct("cloud code").text == "Claude Code") corrector.setEnabled(false) #expect(corrector.correct("cloud code").text == "cloud code") corrector.setEnabled(true) corrector.update(entries: []) #expect(corrector.correct("cloud code").text == "cloud code") } } ``` - [ ] **Step 2: Draai de tests, moeten falen** Run: `swift test --filter "DictionaryStoreTests|DictionaryCorrectorTests"` Expected: compilatiefout `cannot find 'DictionaryStore' in scope`. - [ ] **Step 3: Implementatie** `Sources/SstData/DictionaryStore.swift`: ```swift import Foundation import SstCore /// Owns `dictionary.txt`: reads, writes atomically, and notices edits made in another editor. @MainActor public final class DictionaryStore { public static let header = """ # Sst woordenboek # Eén invoer per regel. Een los woord is een term (voorrang bij het model). # 'gehoord => schrijf' is een correctie, bijvoorbeeld: cloud code => Claude Code """ public let fileURL: URL public private(set) var file = DictionaryFile() public var onChange: (() -> Void)? private var lastText = "" private var watcher: DispatchSourceFileSystemObject? private var debounce: Task? public init(fileURL: URL) { self.fileURL = fileURL } public var entries: [DictionaryEntry] { file.entries } /// Reads the file; creates it with a short header when it does not exist yet. public func load() throws { try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) if !FileManager.default.fileExists(atPath: fileURL.path) { try Self.header.write(to: fileURL, atomically: true, encoding: .utf8) } let text = try String(contentsOf: fileURL, encoding: .utf8) lastText = text file = DictionaryFile(parsing: text) onChange?() } /// Applies an edit and writes the file straight away. public func apply(_ mutate: (inout DictionaryFile) -> Void) throws { var updated = file mutate(&updated) let text = updated.serialized() try text.write(to: fileURL, atomically: true, encoding: .utf8) file = updated lastText = text onChange?() } /// Re-reads the file when its text differs from what we last wrote or read. public func reloadIfChanged() { guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return } lastText = text file = DictionaryFile(parsing: text) onChange?() } /// Watches the folder (editors replace files instead of editing them in place). public func startWatching() { guard watcher == nil else { return } let descriptor = open(fileURL.deletingLastPathComponent().path, O_EVTONLY) guard descriptor >= 0 else { return } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: descriptor, eventMask: .write, queue: .main) source.setEventHandler { [weak self] in MainActor.assumeIsolated { self?.scheduleReload() } } source.setCancelHandler { close(descriptor) } source.resume() watcher = source } public func stopWatching() { watcher?.cancel() watcher = nil debounce?.cancel() } private func scheduleReload() { debounce?.cancel() debounce = Task { [weak self] in try? await Task.sleep(for: .milliseconds(200)) guard !Task.isCancelled else { return } self?.reloadIfChanged() } } } /// Bridges the dictionary to the controller: thread-safe, can be switched off, rebuilt when the dictionary changes. public final class DictionaryCorrector: TextCorrecting, @unchecked Sendable { private let lock = NSLock() private let knowledge: WordKnowledge private var engine: CorrectionEngine private var enabled = true public init(knowledge: WordKnowledge) { self.knowledge = knowledge self.engine = CorrectionEngine(entries: [], knowledge: knowledge) } public func update(entries: [DictionaryEntry]) { let rebuilt = CorrectionEngine(entries: entries, knowledge: knowledge) lock.withLock { engine = rebuilt } } public func setEnabled(_ value: Bool) { lock.withLock { enabled = value } } public func correct(_ text: String) -> CorrectionResult { let (current, isOn) = lock.withLock { (engine, enabled) } return isOn ? current.correct(text) : CorrectionResult(text: text, corrections: []) } } ``` - [ ] **Step 4: Draai alle tests** Run: `swift test` Expected: alle tests PASS (engine-tests uit fase 1 + 54 in `SstDataTests`). De test `watcherNoticesAnEditorSavingTheFile` wacht maximaal 4 s; faalt hij sporadisch, draai hem apart opnieuw voordat je de implementatie wijzigt. - [ ] **Step 5: Commit** ```bash git add Sources/SstData Tests/SstDataTests git commit -m "feat: add DictionaryStore with file watching and DictionaryCorrector" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ## Self-review - **Spec-dekking:** bestandsformaat en behoud van opmerkingen/volgorde/ongeldige regels (Task 1), alle correctieregels uit de spec — hele woorden, glue/koppelteken, langste eerst, geen ketens, termen die gewone woorden zijn, letterlijke vervanging, Cloudflare/cloud codex (Task 2), waarschuwingstabel (Task 3), voorrangslijst met limiet 24 (Task 3), geschiedenis met zoeken, groeperen, beschadigd bestand (Task 4), bestandsmonitor en uit/aan-schakelaar (Task 5). Niet in deze fase: de plaats van de bestanden in Application Support, de koppeling aan `DictationController` en de UI (fase 4 en 5). - **Placeholders:** geen; alle code is uit een werkende, geteste versie overgenomen. - **Typeconsistentie:** `DictionaryEntry.Kind` (`.term`, `.correction(heard:wrote:)`), `CorrectionEngine(entries:knowledge:)`, `EntryWarnings.check(_:among:knowledge:)`, `HistoryEntry(result:)` en `DictionaryStore.apply` komen overeen met wat Task 2–5 en de latere fasen gebruiken. - **Afwijking van de spec (bewust):** "gewoon woord" gebruikt de Engelse woordenlijst van `NaturalLanguage` (alleen gewone woorden) plus de Nederlandse spellingcontrole, niet de Engelse spellingcontrole: die rekent "anthropic" tot de woorden en zou jouw eigen voorbeeldterm onterecht uitsluiten van de hoofdletterregel.