diff --git a/GraphcodeKit/Sources/Templates/PromptTemplate.swift b/GraphcodeKit/Sources/Templates/PromptTemplate.swift index 75f5f4e0..a4a7b68c 100644 --- a/GraphcodeKit/Sources/Templates/PromptTemplate.swift +++ b/GraphcodeKit/Sources/Templates/PromptTemplate.swift @@ -494,8 +494,23 @@ extension TemplateSettings { } /// The carried sub-graph, encoded — the one JSON line `graph:` stores. + /// + /// Canonical, not merely valid: a template is a file people diff and the seeder + /// hashes, so the same graph has to encode to the same bytes every time. Two + /// things in a plain `JSONEncoder` pass break that — key order follows Swift's + /// per-process dictionary seeding, and `LoopGraph.project` carries a "last opened" + /// date minted at encode time — so keys are sorted and that date is zeroed. public static func graphJSON(for graph: LoopGraph) -> String? { - guard let data = try? JSONEncoder().encode(graph) else { return nil } - return String(data: data, encoding: .utf8) + guard let data = try? JSONEncoder().encode(graph), + var object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + if var project = object["project"] as? [String: Any] { + project["lastOpenedAt"] = 0 + object["project"] = project + } + guard + let canonical = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + else { return nil } + return String(data: canonical, encoding: .utf8) } } diff --git a/GraphcodeKit/Sources/Templates/StarterTemplates.swift b/GraphcodeKit/Sources/Templates/StarterTemplates.swift index 7db2380b..cc9337e7 100644 --- a/GraphcodeKit/Sources/Templates/StarterTemplates.swift +++ b/GraphcodeKit/Sources/Templates/StarterTemplates.swift @@ -20,6 +20,13 @@ import Foundation /// | Turn | The two pause rhythms, side by side. | /// | Composite | Work handed along edges, carried inside one shareable file. | /// +/// **One fill, at the top.** Each starter has at most one `{token}`, and it is the +/// first line — `Goal: {goal}` — with everything below it fixed text that refers back +/// ("it", "that branch"). Filling a template is typing over one thing, not hunting +/// the same word through four paragraphs; and the body is short enough to read in +/// the picker before choosing it. A starter's settings never carry a token either: +/// a hole in a done check is a second place to look. +/// /// They are seeded as **real markdown files** rather than held as constants, which /// teaches the format too: open one and the front matter is right there. Seeding runs /// once (see `TemplateStorage.seedStartersIfNeeded`), so a starter you delete stays @@ -45,7 +52,7 @@ public enum StarterTemplates { getTheBuildGreen, reviewTheDiff, raiseTestCoverage, nightlyDependencyReview, watchTheBuild, whereDoesThisLive, whyDidThisBreak, - portWithReview, pairOnThis, + changeFileByFile, pairOnThis, reviewFixVerify, ] } @@ -77,133 +84,122 @@ public enum StarterTemplates { /// The starter at the top of the list, because this is what most work turns into: /// one loop that understands the goal, splits it, and stays to put the pieces back - /// together. It is a **Main** loop on purpose — `MAIN_LOOP.md` names this as the - /// orchestration path ("you explore, find the work splits three ways, and promote") - /// — and it cuts no worktree, since a coordinator reads and steers while its - /// children write. - /// - /// The Artifactory is the team's inbox: where the plan is posted for loops that - /// don't exist yet, where children post what they finished, and the record of every - /// message that passed between them. The brief keeps it in that role — the task is - /// the goal; the board is how the team talks about it. - static var leadATeam: PromptTemplate { + /// together. A **Main** loop on purpose — `MAIN_LOOP.md` names this as the + /// orchestration path — that cuts no worktree, since a coordinator reads and steers + /// while its children write. The Artifactory appears as the team's inbox, and the + /// topic is the loop's to choose from the goal, so there is one thing to fill. + public static var leadATeam: PromptTemplate { starter( id("000000000001"), "Lead a team toward a goal", """ - Lead the work toward this goal: {goal}. - - Start by understanding the project well enough to say what done looks like and \ - where the work splits. Then split it: give each independent piece its own goal \ - loop — `graphcode node create --title --type goal --goal \ - ` — and give anything that needs checking rather than \ - finishing, like a build, a flaky test or a service, a timed loop: `--type time \ - --prompt "/loop 30m "`. Keep the integration for yourself: the \ - goal is met when the pieces fit together, not when each child says it's done. - - The team talks through the Artifactory — treat it as the inbox for this effort, \ - under the topic {topic}. Post your plan there first — `graphcode artifactory post \ - --topic {topic} ` — so every loop you create reads it before \ - starting. Tell each child to post its result there the same way when it's done. Run `graphcode artifactory watch --topic {topic}` so their \ - posts reach you, and `graphcode artifactory sync ` whenever you \ - come back, to catch up on what arrived while you were away. A stuck child gets a \ - direct message: `graphcode node send --follow-up `. - - Finish by checking the assembled result against the goal, posting a closing note \ - to the same topic — done, not done, next — and stopping. + Goal: {goal} + + Lead this rather than doing it all yourself. Work out what done looks like and \ + where the work splits; give each independent piece its own goal loop, and \ + anything that needs watching rather than finishing a timed loop. Keep the \ + integration for yourself. + + The Artifactory is the team's inbox. Post your plan there under one topic named \ + for this goal, have every child post its result there, watch that topic, and \ + sync whenever you come back. Finish by checking the assembled result against \ + the goal and posting a closing note. """) } // MARK: - Main - // Nothing to fill in, nothing to decide. Both of these end when you close them, - // which is the whole type. + // Nothing to fill in but the one line, nothing to decide. Both end when you close + // them, which is the whole type. - static var whereDoesThisLive: PromptTemplate { + public static var whereDoesThisLive: PromptTemplate { starter( id("100000000001"), "Where does this live?", """ - Trace {symbol} through this codebase. Show me where it's defined, everything \ - that reads it, and everything that writes it. Don't change anything — I'm \ - trying to understand the shape before I touch it. + Symbol: {symbol} + + Trace it through this codebase — where it's defined, everything that reads it, \ + everything that writes it. Don't change anything; I'm trying to understand the \ + shape before I touch it. """) } - static var whyDidThisBreak: PromptTemplate { + public static var whyDidThisBreak: PromptTemplate { starter( id("100000000002"), "Why did this break?", """ - Reproduce {symptom} and explain what causes it. Work from the failure back to \ - the line responsible. Stop when you can tell me the cause — I'll decide what to \ - do about it. + Symptom: {symptom} + + Reproduce it and work from the failure back to the line responsible. Stop when \ + you can tell me the cause — I'll decide what to do about it. """) } // MARK: - Goal - // Three, because Goal is the workhorse, and because its three lessons are separate: - // a check that decides, no check at all, and a metric. + // Three, because Goal is the workhorse. None carries a done check with a hole in it: + // the command a project uses is the one thing a template cannot know, so the brief + // asks for it up top and the loop runs it. - static var getTheBuildGreen: PromptTemplate { + public static var getTheBuildGreen: PromptTemplate { starter( id("200000000001"), "Get the build green", """ - The build is failing. Find out why and fix it — the smallest change that works. \ - Don't refactor anything you weren't asked to. + Test command: {test_command} + + It's failing. Find out why and fix it — the smallest change that works, no \ + refactoring you weren't asked for — and run that command until it exits 0. """, - shape: .goalBased, - // The token sits in the *done check*, not the brief: filling it is what teaches - // that a Goal loop stops itself when a command says so, and that ⇥ walks every - // field a template left a hole in. - settings: TemplateSettings(doneCheck: "{test_command}")) + shape: .goalBased) } - static var reviewTheDiff: PromptTemplate { + public static var reviewTheDiff: PromptTemplate { starter( id("200000000002"), "Review the diff on this branch", """ - Review every file changed on {branch} against the conventions already in this \ - codebase. List what must change before merge, most important first, with \ - file:line. Don't fix anything — the list is the deliverable. + Branch: {branch} + + Review every file it changed against the conventions already in this codebase. \ + List what must change before merge, most important first, with file:line. \ + Don't fix anything — the list is the deliverable. """, - // No done check on purpose: "a good review exists" is not a shell command, and - // a Goal loop without one resolves when it has finished the work. The pair of - // this and `getTheBuildGreen` is the lesson. shape: .goalBased) } - static var raiseTestCoverage: PromptTemplate { + public static var raiseTestCoverage: PromptTemplate { starter( id("200000000003"), "Raise test coverage", """ - Add tests for the least-covered code in {area}. Cover the behaviour that would \ - actually break, not the lines that are cheapest to hit. Keep every existing \ - test passing. + Area: {area} + + Add tests for its least-covered code — the behaviour that would actually break, \ + not the lines that are cheapest to hit. Keep every existing test passing, and \ + report the coverage number before and after. """, - shape: .goalBased, - settings: TemplateSettings(metric: "{coverage_command}")) + shape: .goalBased) } // MARK: - Timed - // Both of these also demonstrate following: edit either file and the next run picks - // the change up, which is the half of the design a card has to state. + // Both also demonstrate following: edit either file and the next run picks the + // change up. - static var nightlyDependencyReview: PromptTemplate { + public static var nightlyDependencyReview: PromptTemplate { starter( id("300000000001"), "Nightly dependency review", """ - Check for dependency updates worth taking. For each one: what changed, what it \ - would break here, and whether it's worth doing now. Say "nothing worth taking" \ - if that's the answer — a quiet night is a valid report. + Check for dependency updates worth taking. For each: what changed, what it would \ + break here, and whether it's worth doing now. "Nothing worth taking" is a valid \ + report. """, shape: .timeBased, settings: TemplateSettings(cadence: "daily")) } - static var watchTheBuild: PromptTemplate { + public static var watchTheBuild: PromptTemplate { starter( id("300000000002"), "Watch the build", """ - Check whether the build on {branch} is passing. If it broke since last time, \ - find the commit responsible and say what it changed. If it's still green, say \ - so in one line and stop. + Branch: {branch} + + Check whether its build is passing. If it broke since last time, find the commit \ + responsible and say what changed. If it's green, say so in one line and stop. """, shape: .timeBased, settings: TemplateSettings(cadence: "1h")) @@ -212,23 +208,27 @@ public enum StarterTemplates { // MARK: - Turn // The two pause rhythms, side by side — which is the only way the difference reads. - static var portWithReview: PromptTemplate { + public static var changeFileByFile: PromptTemplate { starter( - id("400000000001"), "Port {area} to {target}", + id("400000000001"), "Make a change, one file at a time", """ - Port {area} to {target}. Work file by file. Before each file you change, tell me \ - what you're about to do and why. + Task: {task} + + Work file by file. Before each file you change, tell me what you're about to do \ + and why. """, shape: .turnBased, settings: TemplateSettings(pausesBeforeWritesOnly: true)) } - static var pairOnThis: PromptTemplate { + public static var pairOnThis: PromptTemplate { starter( id("400000000002"), "Pair on this", """ - Work through {task} with me one step at a time. After each step, stop and tell \ - me what you did and what you think comes next. I'll steer. + Task: {task} + + Work through it with me one step at a time. After each step, stop and tell me \ + what you did and what you think comes next. I'll steer. """, shape: .turnBased, settings: TemplateSettings(pausesBeforeWritesOnly: false)) @@ -236,35 +236,46 @@ public enum StarterTemplates { // MARK: - Composite - /// Three loops and the two hand-offs between them, carried inside one file. This is - /// the template that shows what a composite template is *for*: an orchestration - /// somebody else can start from without drawing the graph. - static var reviewFixVerify: PromptTemplate { + /// Three loops and the two hand-offs between them, carried inside one file. The + /// children carry no tokens: a hole inside a carried graph is nowhere the dialog can + /// show, so it would reach the child as literal text. + public static var reviewFixVerify: PromptTemplate { + // Every id and date here is fixed. The carried graph is re-identified when it is + // applied, so these never reach a real loop — but they do reach the *file*, and + // the seeder decides whether a starter is still the one it wrote by hashing the + // file. Fresh UUIDs on every access would make the composite look edited by us + // at every launch and rewrite it each time. + let epoch = Date(timeIntervalSinceReferenceDate: 0) let reviewer = LoopNode( - title: "Reviewer", loopType: .goalBased, + id: id("500000000101"), title: "Reviewer", loopType: .goalBased, goal: GoalSpec( summary: """ - Review every file changed on {branch} and list what must change, most \ + Review every file changed on this branch and list what must change, most \ important first, with file:line. - """)) + """), + createdAt: epoch) let fixer = LoopNode( - title: "Fixer", loopType: .goalBased, + id: id("500000000102"), title: "Fixer", loopType: .goalBased, goal: GoalSpec( summary: """ Work through the findings you were handed, most important first. Make the \ smallest change that resolves each one. - """)) + """), + createdAt: epoch) let verifier = LoopNode( - title: "Verifier", loopType: .goalBased, + id: id("500000000103"), title: "Verifier", loopType: .goalBased, goal: GoalSpec( - summary: "Confirm nothing the fixer changed broke anything else.", - predicate: "{test_command}")) + summary: + "Run the project's tests and confirm nothing the fixer changed broke anything else."), + createdAt: epoch) var graph = LoopGraph( - project: ProjectRef(path: "review-fix-verify", name: "Review, fix, verify"), + id: id("500000000100"), + project: ProjectRef( + path: "review-fix-verify", name: "Review, fix, verify", lastOpenedAt: epoch), nodes: [reviewer, fixer, verifier]) graph.edges = [ - LoopEdge(from: reviewer.id, to: fixer.id, spec: EdgeSpec()), - LoopEdge(from: fixer.id, to: verifier.id, spec: EdgeSpec()), + LoopEdge(id: id("500000000111"), from: reviewer.id, to: fixer.id, spec: EdgeSpec()), + LoopEdge(id: id("500000000112"), from: fixer.id, to: verifier.id, spec: EdgeSpec()), ] return starter( id("500000000001"), "Review, fix, verify", diff --git a/GraphcodeKit/Sources/Templates/TemplateStorage.swift b/GraphcodeKit/Sources/Templates/TemplateStorage.swift index 055e7913..11f3a2ee 100644 --- a/GraphcodeKit/Sources/Templates/TemplateStorage.swift +++ b/GraphcodeKit/Sources/Templates/TemplateStorage.swift @@ -89,58 +89,103 @@ public struct TemplateStorage: Sendable { // MARK: - Starters - /// Writes the templates the app ships with into the home folder — each of them - /// **once**. + /// Writes the templates the app ships with into the home folder, and keeps the ones + /// nobody has touched current. /// /// A fresh install has an empty library, and an empty ⌘T picker teaches nothing — /// see `StarterTemplates` for what the briefs are chosen to demonstrate. They are /// written as real files so they read, diff and edit like any other template. /// - /// The marker in the home folder lists the ids this install has already seeded, and - /// that is what keeps this from being annoying: - /// - **Once per starter.** A starter whose id is in the marker is never written - /// again, so one you deleted stays deleted — and a starter added in a later build - /// still arrives, because its id isn't there yet. - /// - **Never over anything.** A file already at that name is somebody's, and is - /// left exactly as it is even on a first run. + /// The marker in the home folder records, per starter id, a hash of the file this + /// install last wrote for it. That one fact answers all three questions: + /// - **New here?** An id not in the marker is written (never over an existing file + /// — that one is somebody's) and recorded. A starter added in a later build + /// arrives this way. + /// - **Deleted?** An id in the marker whose file is gone stays gone. + /// - **Changed by us, untouched by you?** A file whose hash still matches what we + /// wrote is ours to refresh when the shipped text changes. One you edited hashes + /// differently and is left exactly as it is. /// - /// Returns what it actually wrote, which is empty on every launch that adds nothing. + /// Returns what it actually wrote, which is empty on every launch that changes nothing. @discardableResult public func seedStartersIfNeeded(_ starters: [PromptTemplate] = StarterTemplates.all) throws -> [PromptTemplate] { let marker = homeDirectory.appendingPathComponent(Self.seededMarker) - var seeded = seededIDs(at: marker) - let pending = starters.filter { !seeded.contains($0.id) } - guard !pending.isEmpty else { return [] } - try FileManager.default.createDirectory(at: homeDirectory, withIntermediateDirectories: true) + var record = seedRecord(at: marker) var written: [PromptTemplate] = [] - for starter in pending { + var changed = false + for starter in starters { + var copy = starter + copy.origin = .home + let shipped = TemplateFileCodec.encode(copy) + let shippedHash = Self.contentHash(shipped) let url = homeDirectory.appendingPathComponent(starter.fileName) - if !FileManager.default.fileExists(atPath: url.path) { - var copy = starter - copy.origin = .home - try TemplateFileCodec.encode(copy).write(to: url, atomically: true, encoding: .utf8) - written.append(copy) + let exists = FileManager.default.fileExists(atPath: url.path) + + guard let recorded = record[starter.id] else { + if !exists { + try FileManager.default.createDirectory( + at: homeDirectory, withIntermediateDirectories: true) + try shipped.write(to: url, atomically: true, encoding: .utf8) + written.append(copy) + } + record[starter.id] = shippedHash + changed = true + continue } - seeded.insert(starter.id) + guard exists, recorded != shippedHash, + let onDisk = try? String(contentsOf: url, encoding: .utf8) + else { continue } + // A legacy record (beta2/beta3, before hashes) knows only that the file was + // seeded. There, the starter mark standing in the file is what "untouched" + // means — the one launch where an edited beta starter would be refreshed too. + let untouched = + recorded.isEmpty + ? TemplateFileCodec.decode(onDisk, origin: .home)?.isStarter == true + : Self.contentHash(onDisk) == recorded + guard untouched else { continue } + try shipped.write(to: url, atomically: true, encoding: .utf8) + written.append(copy) + record[starter.id] = shippedHash + changed = true + } + if changed { + // Written last and whole: a run that threw half way through should try again. + try FileManager.default.createDirectory(at: homeDirectory, withIntermediateDirectories: true) + try record.map { "\($0.key.uuidString) \($0.value)" }.sorted().joined(separator: "\n") + .write(to: marker, atomically: true, encoding: .utf8) } - // The marker is written last and whole: a run that threw half way through should - // try again, not leave someone with four of the eleven. - try seeded.map(\.uuidString).sorted().joined(separator: "\n") - .write(to: marker, atomically: true, encoding: .utf8) return written } - /// The ids the marker records. An **empty** marker is the one 0.1.58-beta2 and - /// beta3 wrote, before ids were recorded: those installs seeded every starter that - /// existed at the time, so the empty file is read as exactly that set — the ones - /// shipped since are what a later launch still owes them. - private func seededIDs(at marker: URL) -> Set { - guard let text = try? String(contentsOf: marker, encoding: .utf8) else { return [] } - let ids = text.split(separator: "\n").compactMap { UUID(uuidString: String($0)) } - if ids.isEmpty { return StarterTemplates.seededBeforeIDsWereRecorded } - return Set(ids) + /// `id hash` per line. An **empty** marker is the one 0.1.58-beta2 and beta3 wrote, + /// before anything was recorded: those installs seeded every starter that existed + /// then, so the empty file reads as exactly that set, with no hash to compare. + private func seedRecord(at marker: URL) -> [UUID: String] { + guard let text = try? String(contentsOf: marker, encoding: .utf8) else { return [:] } + var record: [UUID: String] = [:] + for line in text.split(separator: "\n") { + let parts = line.split(separator: " ", maxSplits: 1) + guard let first = parts.first, let id = UUID(uuidString: String(first)) else { continue } + record[id] = parts.count > 1 ? String(parts[1]) : "" + } + if record.isEmpty { + for id in StarterTemplates.seededBeforeIDsWereRecorded { record[id] = "" } + } + return record + } + + /// FNV-1a over the file's UTF-8, in hex. Not cryptographic and not meant to be — + /// it only has to answer "is this the file we wrote", and it has to do so on the + /// Linux CI build, where CryptoKit is not available. + static func contentHash(_ text: String) -> String { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in text.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + return String(hash, radix: 16) } /// A dotfile, so it never shows up as a template — `read` skips hidden files. diff --git a/graphcode/Tests/StarterTemplateTests.swift b/graphcode/Tests/StarterTemplateTests.swift index 37b5c96f..8a654b60 100644 --- a/graphcode/Tests/StarterTemplateTests.swift +++ b/graphcode/Tests/StarterTemplateTests.swift @@ -36,15 +36,11 @@ struct StarterTemplateTests { for main in StarterTemplates.all where main.shape == nil { #expect(main.settings == nil) } - // Goal shows all three of its cases: a check that decides, no check at all, and - // a metric. + // Goal is the workhorse and gets three. None carries a setting with a hole in + // it — see `theOneFillRule` — so the three differ by brief, not by settings. let goals = StarterTemplates.all.filter { $0.shape == .goalBased } - let withCheck = goals.filter { $0.settings?.doneCheck != nil }.count - let withoutCheck = goals.filter { $0.settings?.doneCheck == nil }.count - let withMetric = goals.filter { $0.settings?.metric != nil }.count - #expect(withCheck > 0) - #expect(withoutCheck > 0) - #expect(withMetric > 0) + #expect(goals.count == 3) + #expect(Set(goals.map(\.name)).count == 3) // Timed always carries a cadence — a timed loop without one is not the type. for timed in StarterTemplates.all where timed.shape == .timeBased { #expect(timed.settings?.cadence?.isEmpty == false) @@ -81,6 +77,16 @@ struct StarterTemplateTests { #expect(Set(StarterTemplates.all.map(\.fileName)).count == StarterTemplates.all.count) } + /// The seeder decides whether a file is still the one it wrote by hashing what it + /// would write; a starter that encoded differently on every access would look + /// edited-by-us at every launch and be rewritten each time. + @Test + func everyStarterEncodesIdentically() { + let once = StarterTemplates.all.map { TemplateFileCodec.encode($0) } + let twice = StarterTemplates.all.map { TemplateFileCodec.encode($0) } + #expect(once == twice) + } + /// The three offered on an empty canvas climb the commitment ladder — that is what /// makes the row a lesson rather than three arbitrary briefs. @Test @@ -98,13 +104,50 @@ struct StarterTemplateTests { } } - /// Every token is a hole somebody can be expected to fill from where they're - /// standing — and the brief has to say enough for them to know what to put in it. + /// **One fill, at the top.** At most one token per starter, appearing exactly once, + /// on the first line — and never in a setting. Filling a template is typing over + /// one thing, not hunting the same word through four paragraphs. + @Test + func theOneFillRule() { + for template in StarterTemplates.all { + let tokens = template.tokens + #expect(tokens.count <= 1, "\(template.name) asks for \(tokens)") + if let token = tokens.first { + let occurrences = template.body.components(separatedBy: "{\(token)}").count - 1 + #expect(occurrences == 1, "\(template.name) repeats {\(token)} \(occurrences)×") + let firstLine = + template.body.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? "" + #expect(firstLine.contains("{\(token)}"), "\(template.name)'s hole isn't on its first line") + } + for setting in [ + template.settings?.doneCheck, template.settings?.metric, template.settings?.branch, + template.settings?.cadence, + ] { + #expect( + PromptTemplate.tokens(in: setting ?? "").isEmpty, + "\(template.name) hides a hole in a setting") + } + // A carried graph is nowhere the dialog can show a hole, so it must have none. + for child in template.settings?.carriedGraph?.nodes ?? [] { + let inSummary = PromptTemplate.tokens(in: child.goal?.summary ?? "") + let inPredicate = PromptTemplate.tokens(in: child.goal?.predicate ?? "") + #expect(inSummary.isEmpty, "\(child.title) carries \(inSummary)") + #expect(inPredicate.isEmpty, "\(child.title) carries \(inPredicate)") + } + // Names are names, not fill-in forms. + #expect( + PromptTemplate.tokens(in: template.name).isEmpty, "\(template.name) has a hole in its name") + } + } + + /// Short enough to read in the picker before choosing it. The leader is the longest + /// on purpose and still under a hundred words; the rest are a few sentences. @Test - func everyStarterReadsAsAFinishedBrief() { + func everyStarterIsShort() { for template in StarterTemplates.all { - #expect(!template.name.isEmpty) - #expect(template.body.count > 40, "\(template.name) is too terse to teach anything") + let words = template.body.split(whereSeparator: \.isWhitespace).count + #expect(words <= 100, "\(template.name) is \(words) words") + #expect(words >= 15, "\(template.name) is too terse to teach anything") #expect(template.isStarter) #expect(template.origin == .home) } @@ -118,16 +161,15 @@ struct StarterTemplateTests { #expect(lead.name == "Lead a team toward a goal") #expect(lead.shape == nil) #expect(lead.settings == nil) - #expect(lead.tokens == ["goal", "topic"]) - for command in [ - "graphcode node create", "--type goal", "--type time", "/loop 30m", - "graphcode artifactory watch", "graphcode artifactory sync", "graphcode artifactory post", - "graphcode node send", "--follow-up", - ] { - #expect(lead.body.contains(command), "missing \(command)") + #expect(lead.tokens == ["goal"]) + // Task first: the goal is the first line, and the only thing to fill. + #expect(lead.body.hasPrefix("Goal: {goal}\n")) + // The method in words, not a CLI transcript — the loop's own instructions carry + // the flags. What the brief has to say is which loop type for which piece, and + // that the Artifactory is the inbox. + for phrase in ["goal loop", "timed loop", "Artifactory", "inbox", "topic", "closing note"] { + #expect(lead.body.contains(phrase), "missing \(phrase)") } - // Task first: the goal is the first thing the brief says. - #expect(lead.body.hasPrefix("Lead the work toward this goal: {goal}.")) } // MARK: - Seeding @@ -150,6 +192,56 @@ struct StarterTemplateTests { #expect(!names.contains(deleted.name)) } + /// A starter nobody edited is ours to keep current: when the shipped text changes, + /// the file is refreshed. One somebody edited is theirs, and is left alone. + @Test + func untouchedStartersRefreshAndEditedOnesDoNot() throws { + var first = StarterTemplates.whereDoesThisLive + first.body = "The old text this install was shipped." + var second = StarterTemplates.whyDidThisBreak + second.body = "Another old text." + try storage.seedStartersIfNeeded([first, second]) + + // The person edits the second one. + let mine = try #require(storage.load(projectPath: nil).first { $0.id == second.id }) + var edited = mine + edited.body = "My own rewrite." + try storage.update(edited, replacing: mine) + + // A new build ships new text for both. + let refreshed = try storage.seedStartersIfNeeded([ + StarterTemplates.whereDoesThisLive, StarterTemplates.whyDidThisBreak, + ]) + #expect(refreshed.map(\.id) == [first.id]) + let bodies = Dictionary( + uniqueKeysWithValues: storage.load(projectPath: nil).map { ($0.id, $0.body) }) + #expect(bodies[first.id] == StarterTemplates.whereDoesThisLive.body) + #expect(bodies[second.id] == "My own rewrite.") + // And nothing further to do next launch. + let again = try storage.seedStartersIfNeeded([ + StarterTemplates.whereDoesThisLive, StarterTemplates.whyDidThisBreak, + ]) + #expect(again.isEmpty) + } + + /// A beta2/beta3 install has the old bodies and a marker with no hashes. Its + /// starters that still carry the mark are refreshed once, so the shorter briefs + /// reach the people who asked for them. + @Test + func aLegacyInstallGetsTheNewBodiesOnce() throws { + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + var old = StarterTemplates.getTheBuildGreen + old.body = "The build is failing. A long old brief with {test_command} in the done check." + try TemplateFileCodec.encode(old) + .write(to: home.appendingPathComponent(old.fileName), atomically: true, encoding: .utf8) + try Data().write(to: home.appendingPathComponent(TemplateStorage.seededMarker)) + + let refreshed = try storage.seedStartersIfNeeded([StarterTemplates.getTheBuildGreen]) + #expect(refreshed.map(\.name) == ["Get the build green"]) + #expect( + storage.load(projectPath: nil).first?.body == StarterTemplates.getTheBuildGreen.body) + } + /// beta2 and beta3 wrote an empty marker. That install seeded everything that /// existed then, so the empty file has to read as exactly that set — the one /// starter added since is what it is still owed, and nothing it deleted returns. diff --git a/graphcode/Tests/TemplatePickerTests.swift b/graphcode/Tests/TemplatePickerTests.swift index 2ee6a7ea..a55463cd 100644 --- a/graphcode/Tests/TemplatePickerTests.swift +++ b/graphcode/Tests/TemplatePickerTests.swift @@ -265,9 +265,10 @@ struct TemplatePickerTests { #expect(store.state.showingNewNodeForm) #expect(store.state.draftLoopType == .goalBased) #expect(store.state.templates.applied?.name == "Get the build green") - // Its token lives in the done check, so that is what Start is waiting on. + // One thing to fill, on the brief's first line. #expect(store.state.unfilledTokens == ["test_command"]) #expect(store.state.draftBlocksOnTokens) + #expect(store.state.draftGoal.hasPrefix("Test command: {test_command}")) } @Test