@pmelab/gtd 24.3.0 → 25.0.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-plugin/plugin.json +1 -1
- package/README.md +28 -22
- package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +780 -813
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +666 -161
- package/dist/workflow-kit.mjs +55 -96
- package/dist/workflows/feature.mjs +171 -76
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +353 -310
- package/package.json +1 -2
- package/src/cli/Program.test.ts +3 -8
- package/src/cli/Program.ts +1 -8
- package/src/edge/Edge.test.ts +5 -2
- package/src/edge/Edge.ts +3 -4
- package/src/emit/index.ts +1 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/replay/Replay.ts +2 -1
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +148 -51
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +140 -49
- package/src/testkit/InMemRepo.ts +35 -2
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflow-kit/scenarios.test.ts +116 -0
- package/src/workflow-kit/scenarios.ts +53 -113
- package/src/workflows/feature/feature.ts +2 -5
- package/src/workflows/feature/packages.test.ts +17 -34
- package/src/workflows/feature/packages.ts +13 -52
- package/src/workflows/feature/planning.ts +1 -1
- package/src/workflows/feature/review.test.ts +233 -54
- package/src/workflows/feature/review.ts +178 -41
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/index.ts +0 -2
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/health.test.ts +1 -21
- package/src/workflows/shared/health.ts +4 -39
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -1
- package/src/workflows/shared/steps.ts +18 -11
- package/src/workflows/shared/tail.test.ts +1 -41
- package/src/workflows/shared/tail.ts +17 -41
- package/src/workflows/shared/text.fixture.ts +1 -0
- package/src/workflows/shared/text.test.ts +129 -41
- package/src/workflows/shared/text.ts +111 -75
- package/src/workflows/shared/vars.ts +1 -1
- package/src/workflows/shared/scenarios.test.ts +0 -256
- package/src/workflows/shared/scenarios.ts +0 -35
|
@@ -1,9 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
symlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs"
|
|
2
9
|
import { tmpdir } from "node:os"
|
|
3
10
|
import { join } from "node:path"
|
|
4
11
|
import { describe, expect, it } from "vitest"
|
|
5
12
|
import { changedConfigFiles, configFingerprint } from "./fingerprint.js"
|
|
6
13
|
|
|
14
|
+
const read = (path: string): string | undefined => {
|
|
15
|
+
try {
|
|
16
|
+
return readFileSync(path, "utf8")
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
describe("the config fingerprint", () => {
|
|
8
23
|
it("hashes each file under its root-relative path, skipping one that cannot be read", () => {
|
|
9
24
|
const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
|
|
@@ -14,6 +29,7 @@ describe("the config fingerprint", () => {
|
|
|
14
29
|
const fingerprint = configFingerprint(
|
|
15
30
|
[join(root, "gtd.config.ts"), join(root, "lib", "flow.ts"), join(root, "gone.ts")],
|
|
16
31
|
root,
|
|
32
|
+
read,
|
|
17
33
|
)
|
|
18
34
|
|
|
19
35
|
expect(Object.keys(fingerprint).sort()).toEqual(["gtd.config.ts", "lib/flow.ts"])
|
|
@@ -22,7 +38,7 @@ describe("the config fingerprint", () => {
|
|
|
22
38
|
expect(
|
|
23
39
|
changedConfigFiles(
|
|
24
40
|
fingerprint,
|
|
25
|
-
configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root),
|
|
41
|
+
configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root, read),
|
|
26
42
|
),
|
|
27
43
|
).toEqual(["lib/flow.ts"])
|
|
28
44
|
})
|
|
@@ -34,7 +50,7 @@ describe("the config fingerprint", () => {
|
|
|
34
50
|
symlinkSync(real, link)
|
|
35
51
|
|
|
36
52
|
expect(
|
|
37
|
-
Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link)),
|
|
53
|
+
Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link, read)),
|
|
38
54
|
).toEqual(["gtd.config.ts"])
|
|
39
55
|
})
|
|
40
56
|
|
|
@@ -42,7 +58,7 @@ describe("the config fingerprint", () => {
|
|
|
42
58
|
const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
|
|
43
59
|
writeFileSync(join(root, "a\x1b.ts"), "export const a = 1\n")
|
|
44
60
|
|
|
45
|
-
expect(configFingerprint([join(root, "a\x1b.ts")], root)).toEqual({})
|
|
61
|
+
expect(configFingerprint([join(root, "a\x1b.ts")], root, read)).toEqual({})
|
|
46
62
|
})
|
|
47
63
|
|
|
48
64
|
it("names a file only one side has as changed", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto"
|
|
2
|
-
import {
|
|
2
|
+
import { realpathSync } from "node:fs"
|
|
3
3
|
import { relative, sep } from "node:path"
|
|
4
4
|
|
|
5
5
|
/** Path (relative to `root`) → content hash of every file a config load touched. */
|
|
@@ -27,19 +27,18 @@ const realOr = (path: string): string => {
|
|
|
27
27
|
// feeds a warning, and the load that listed the file already read it.
|
|
28
28
|
// Paths are real paths: a loader may report `/private/var/…` for a root
|
|
29
29
|
// reached through the `/var` symlink, and the two must still relate.
|
|
30
|
-
export const configFingerprint = (
|
|
30
|
+
export const configFingerprint = (
|
|
31
|
+
files: readonly string[],
|
|
32
|
+
root: string,
|
|
33
|
+
read: (path: string) => string | undefined,
|
|
34
|
+
): ConfigFingerprint => {
|
|
31
35
|
const fingerprint: Record<string, string> = {}
|
|
32
36
|
const realRoot = realOr(root)
|
|
33
37
|
for (const file of files) {
|
|
34
38
|
const path = relative(realRoot, realOr(file)).split(sep).join("/")
|
|
35
39
|
if (CONTROL_CHARACTER.test(path)) continue
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
content = readFileSync(file)
|
|
39
|
-
} catch {
|
|
40
|
-
continue
|
|
41
|
-
}
|
|
42
|
-
fingerprint[path] = contentHash(content)
|
|
40
|
+
const content = read(file)
|
|
41
|
+
if (content !== undefined) fingerprint[path] = contentHash(content)
|
|
43
42
|
}
|
|
44
43
|
return fingerprint
|
|
45
44
|
}
|
package/src/workflow/index.ts
CHANGED
|
@@ -25,9 +25,4 @@ export {
|
|
|
25
25
|
type StateMode,
|
|
26
26
|
type StateName,
|
|
27
27
|
} from "./Workflow.js"
|
|
28
|
-
export {
|
|
29
|
-
changedConfigFiles,
|
|
30
|
-
configFingerprint,
|
|
31
|
-
contentHash,
|
|
32
|
-
type ConfigFingerprint,
|
|
33
|
-
} from "./fingerprint.js"
|
|
28
|
+
export { changedConfigFiles, contentHash, type ConfigFingerprint } from "./fingerprint.js"
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { execSync } from "node:child_process"
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
mkdtempSync,
|
|
6
|
-
realpathSync,
|
|
7
|
-
rmSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
mkdirSync,
|
|
10
|
-
} from "node:fs"
|
|
2
|
+
import { existsSync, readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"
|
|
11
3
|
import { join } from "node:path"
|
|
12
4
|
import { tmpdir } from "node:os"
|
|
13
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
@@ -310,7 +302,7 @@ describe("ConfigService", () => {
|
|
|
310
302
|
expect(cfg.workflow.steering).toEqual({ ".gtd/PLAN.md": "qa" })
|
|
311
303
|
})
|
|
312
304
|
|
|
313
|
-
it("
|
|
305
|
+
it("the fingerprint covers gtd.config.ts AND a sibling module it imports from", async () => {
|
|
314
306
|
writeFileSync(
|
|
315
307
|
join(projectDir, "steps.ts"),
|
|
316
308
|
[`export const steering = { ".gtd/PLAN.md": "qa" }`, ``].join("\n"),
|
|
@@ -323,19 +315,13 @@ describe("ConfigService", () => {
|
|
|
323
315
|
const cfg = await getConfig()
|
|
324
316
|
|
|
325
317
|
expect(cfg.workflow.steering[".gtd/PLAN.md"]).toBe("qa")
|
|
326
|
-
expect(cfg.
|
|
327
|
-
expect.arrayContaining([
|
|
328
|
-
realpathSync(join(projectDir, "gtd.config.ts")),
|
|
329
|
-
realpathSync(join(projectDir, "steps.ts")),
|
|
330
|
-
]),
|
|
331
|
-
)
|
|
318
|
+
expect(Object.keys(cfg.fingerprint).sort()).toEqual(["gtd.config.ts", "steps.ts"])
|
|
332
319
|
})
|
|
333
320
|
|
|
334
|
-
it("
|
|
321
|
+
it("the fingerprint covers gtd.config.ts but no shipped module it imports", async () => {
|
|
335
322
|
const cfg = await getConfig()
|
|
336
323
|
|
|
337
|
-
expect(cfg.
|
|
338
|
-
expect(cfg.workflowFiles[0]).toMatch(/\/gtd\.config\.ts$/)
|
|
324
|
+
expect(Object.keys(cfg.fingerprint)).toEqual(["gtd.config.ts"])
|
|
339
325
|
})
|
|
340
326
|
|
|
341
327
|
it.each([
|
package/src/workflow/load.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
|
|
|
12
12
|
import { compileConfig, type CompiledConfig } from "./compile.js"
|
|
13
13
|
import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
|
|
14
14
|
import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
|
|
15
|
+
import { configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
|
|
15
16
|
import {
|
|
16
17
|
dedupeDiagnostics,
|
|
17
18
|
formatDiagnostic,
|
|
@@ -41,8 +42,8 @@ export interface ConfigOperations {
|
|
|
41
42
|
readonly ui?: flows.UiSettings
|
|
42
43
|
/** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
|
|
43
44
|
readonly warnings: readonly Diagnostic[]
|
|
44
|
-
/**
|
|
45
|
-
readonly
|
|
45
|
+
/** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
|
|
46
|
+
readonly fingerprint: ConfigFingerprint
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
// Everything lives in `message` (not `GtdError.detail`) — `renderFailure`
|
|
@@ -175,10 +176,22 @@ export const load: Effect.Effect<
|
|
|
175
176
|
},
|
|
176
177
|
...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
|
|
177
178
|
warnings: diagnostics.filter((d) => d.severity === "warning"),
|
|
178
|
-
|
|
179
|
+
fingerprint: configFingerprint(
|
|
180
|
+
loaded.file.dependencyFiles,
|
|
181
|
+
host.root,
|
|
182
|
+
module.read ?? readFromDisk,
|
|
183
|
+
),
|
|
179
184
|
}
|
|
180
185
|
})
|
|
181
186
|
|
|
187
|
+
const readFromDisk = (path: string): string | undefined => {
|
|
188
|
+
try {
|
|
189
|
+
return readFileSync(path, "utf8")
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
182
195
|
interface Settings {
|
|
183
196
|
readonly defaults: Readonly<Record<string, string>>
|
|
184
197
|
readonly envDefaults: Readonly<Record<string, string>>
|
|
@@ -253,7 +266,14 @@ const memoized = (transform: Transform): Transform => {
|
|
|
253
266
|
|
|
254
267
|
let jitiModule: JitiModule | undefined
|
|
255
268
|
let transform: Transform | undefined
|
|
256
|
-
|
|
269
|
+
/** Relative specifiers a module imports, served from somewhere other than the disk. */
|
|
270
|
+
interface LocalModules {
|
|
271
|
+
/** The path a specifier names, or nothing when it names no module. */
|
|
272
|
+
readonly resolve: (specifier: string) => string | undefined
|
|
273
|
+
readonly load: (path: string) => unknown
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const jiti = (shipped: ShippedModules, local?: LocalModules): JitiInstance => {
|
|
257
277
|
const require = createRequire(import.meta.url)
|
|
258
278
|
jitiModule ??= require("jiti") as JitiModule
|
|
259
279
|
// jiti's own Babel transform, which its package exports don't name.
|
|
@@ -261,7 +281,16 @@ const jiti = (shipped: ShippedModules): JitiInstance => {
|
|
|
261
281
|
require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")) as Transform,
|
|
262
282
|
)
|
|
263
283
|
return jitiModule.createJiti(import.meta.url, {
|
|
264
|
-
|
|
284
|
+
// Consulted when a module is required, so a local module is evaluated
|
|
285
|
+
// only once an import actually runs.
|
|
286
|
+
virtualModules: new Proxy(shipped.modules, {
|
|
287
|
+
has: (modules, id) =>
|
|
288
|
+
id in modules || (typeof id === "string" && local?.resolve(id) !== undefined),
|
|
289
|
+
get: (modules, id) => {
|
|
290
|
+
const path = typeof id === "string" ? local?.resolve(id) : undefined
|
|
291
|
+
return path === undefined ? modules[id as string] : local!.load(path)
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
265
294
|
alias: shipped.missing,
|
|
266
295
|
// No transpile cache on disk, and a fresh module every load — nothing a
|
|
267
296
|
// later command could read back instead of the source.
|
|
@@ -572,11 +601,12 @@ const namespaceOf = (exported: unknown): Record<string, unknown> =>
|
|
|
572
601
|
* pulled in, transitively.
|
|
573
602
|
*/
|
|
574
603
|
const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded => {
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const
|
|
604
|
+
const { exports: evaluated, files } =
|
|
605
|
+
module.read === undefined
|
|
606
|
+
? evaluateFromDisk(module, shipped)
|
|
607
|
+
: evaluateRead(module, module.read, shipped)
|
|
608
|
+
const exports = namespaceOf(evaluated)
|
|
609
|
+
const file = fileFrom(exports, module.filepath, files)
|
|
580
610
|
const exported = Object.entries(exports).filter(
|
|
581
611
|
([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
|
|
582
612
|
)
|
|
@@ -611,6 +641,60 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
|
|
|
611
641
|
}
|
|
612
642
|
}
|
|
613
643
|
|
|
644
|
+
const evaluateFromDisk = (
|
|
645
|
+
module: WorkflowModule,
|
|
646
|
+
shipped: ShippedModules,
|
|
647
|
+
): { readonly exports: unknown; readonly files: readonly string[] } => {
|
|
648
|
+
const cache: Record<string, unknown> = {}
|
|
649
|
+
const exports = jiti(shipped).evalModule(module.source, { filename: module.filepath, cache })
|
|
650
|
+
return { exports, files: Object.keys(cache) }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// How jiti finds a TypeScript module behind a specifier: as written, its
|
|
654
|
+
// `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
|
|
655
|
+
const moduleCandidates = (path: string): readonly string[] => [
|
|
656
|
+
path,
|
|
657
|
+
path.replace(/\.js$/, ".ts"),
|
|
658
|
+
path.replace(/\.mjs$/, ".mts"),
|
|
659
|
+
`${path}.ts`,
|
|
660
|
+
join(path, "index.ts"),
|
|
661
|
+
]
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Evaluates the config with every local module it imports taken from `read`
|
|
665
|
+
* instead of the disk. jiti matches a virtual module on the specifier as
|
|
666
|
+
* written, so each module gets its own instance resolving against its own
|
|
667
|
+
* directory. A specifier `read` cannot serve falls through to the disk, and
|
|
668
|
+
* fails the way a missing file does.
|
|
669
|
+
*/
|
|
670
|
+
const evaluateRead = (
|
|
671
|
+
module: WorkflowModule,
|
|
672
|
+
read: (path: string) => string | undefined,
|
|
673
|
+
shipped: ShippedModules,
|
|
674
|
+
): { readonly exports: unknown; readonly files: readonly string[] } => {
|
|
675
|
+
const files = [module.filepath]
|
|
676
|
+
const evaluated = new Map<string, unknown>()
|
|
677
|
+
const evaluate = (filepath: string, source: string): unknown =>
|
|
678
|
+
jiti(shipped, {
|
|
679
|
+
resolve: (specifier) =>
|
|
680
|
+
/^\.\.?\//.test(specifier)
|
|
681
|
+
? moduleCandidates(join(dirname(filepath), specifier)).find(
|
|
682
|
+
(candidate) => read(candidate) !== undefined,
|
|
683
|
+
)
|
|
684
|
+
: undefined,
|
|
685
|
+
load: (path) => {
|
|
686
|
+
if (!evaluated.has(path)) {
|
|
687
|
+
// A placeholder first, so an import cycle ends here.
|
|
688
|
+
evaluated.set(path, {})
|
|
689
|
+
files.push(path)
|
|
690
|
+
evaluated.set(path, evaluate(path, read(path)!))
|
|
691
|
+
}
|
|
692
|
+
return evaluated.get(path)
|
|
693
|
+
},
|
|
694
|
+
}).evalModule(source, { filename: filepath })
|
|
695
|
+
return { exports: evaluate(module.filepath, module.source), files }
|
|
696
|
+
}
|
|
697
|
+
|
|
614
698
|
const isWorkflow = (value: unknown): boolean =>
|
|
615
699
|
typeof value === "function" || flows.isFactoryWorkflow(value)
|
|
616
700
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
const state = vi.hoisted(() => ({ feedback: undefined as string | undefined, head: "h1" }))
|
|
4
|
+
|
|
5
|
+
vi.mock("../flows/index.js", () => ({
|
|
6
|
+
changes: () => [],
|
|
7
|
+
changesSince: () => [],
|
|
8
|
+
hasThreadFor: () => false,
|
|
9
|
+
head: () => state.head,
|
|
10
|
+
read: () => state.feedback,
|
|
11
|
+
refuse: (m: string) => {
|
|
12
|
+
throw new Error(m)
|
|
13
|
+
},
|
|
14
|
+
requireReplies: () => {},
|
|
15
|
+
requireThreadsClosed: () => {},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import { routeNotes, type ReviewPolicy, type Verdict } from "./review.js"
|
|
19
|
+
import type { ReviewNote } from "../steering/index.js"
|
|
20
|
+
|
|
21
|
+
const note = (id: string) => ({ id }) as unknown as ReviewNote
|
|
22
|
+
|
|
23
|
+
const setup = (verdicts: Record<string, Verdict>) => {
|
|
24
|
+
const calls: string[] = []
|
|
25
|
+
const deferred: string[][] = []
|
|
26
|
+
const collected: ReviewNote[][] = []
|
|
27
|
+
const p = {
|
|
28
|
+
file: "r.md",
|
|
29
|
+
feedback: "f.md",
|
|
30
|
+
triage: async () => new Map(Object.entries(verdicts)),
|
|
31
|
+
answerQuestions: async () => void calls.push("answer"),
|
|
32
|
+
fixNits: async () => void calls.push("nits"),
|
|
33
|
+
keepGreen: async () => void calls.push("green"),
|
|
34
|
+
collecting: async () => {
|
|
35
|
+
calls.push("collect")
|
|
36
|
+
state.feedback = "x"
|
|
37
|
+
},
|
|
38
|
+
captures: {
|
|
39
|
+
editNotes: (_r: string, edits: readonly ReviewNote[]) => {
|
|
40
|
+
collected.push([...edits])
|
|
41
|
+
return "cap"
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
defer: async (ns: readonly ReviewNote[]) => {
|
|
45
|
+
calls.push("defer")
|
|
46
|
+
deferred.push(ns.map((n) => n.id))
|
|
47
|
+
},
|
|
48
|
+
} as unknown as ReviewPolicy
|
|
49
|
+
const r = {
|
|
50
|
+
round: "r1",
|
|
51
|
+
close: async () => void calls.push("close"),
|
|
52
|
+
outcome: (verdict: "signoff" | "feedback") => ({ verdict }) as never,
|
|
53
|
+
unfolded: async () => ({ verdict: "signoff" }) as never,
|
|
54
|
+
}
|
|
55
|
+
return { p, r, calls, deferred, collected }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
state.feedback = undefined
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("routeNotes defer", () => {
|
|
63
|
+
it("defer-only round awaits again, no collecting turn", async () => {
|
|
64
|
+
const t = setup({ a: "defer", b: "defer" })
|
|
65
|
+
const res = await routeNotes(t.p, [note("a"), note("b")], t.r)
|
|
66
|
+
expect(res).toEqual({ next: "await", reviewed: "r1" })
|
|
67
|
+
expect(t.deferred).toEqual([["a", "b"]])
|
|
68
|
+
expect(t.calls).toEqual(["defer"])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("defer + edit collects only the edits and ends feedback", async () => {
|
|
72
|
+
const t = setup({ a: "defer", b: "edit" })
|
|
73
|
+
const res = await routeNotes(t.p, [note("a"), note("b")], t.r)
|
|
74
|
+
expect(res).toEqual({ verdict: "feedback" })
|
|
75
|
+
expect(t.calls).toEqual(["defer", "close", "collect"])
|
|
76
|
+
expect(t.collected[0]!.map((n) => n.id)).toEqual(["b"])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("runs after nit fixes and before the collecting turn", async () => {
|
|
80
|
+
const t = setup({ a: "nit", b: "defer", c: "edit" })
|
|
81
|
+
await routeNotes(t.p, [note("a"), note("b"), note("c")], t.r)
|
|
82
|
+
expect(t.calls).toEqual(["nits", "green", "defer", "close", "collect"])
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -24,7 +24,7 @@ export type ReviewOutcome =
|
|
|
24
24
|
readonly edited: readonly Change[]
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export type Verdict = "edit" | "question" | "nit" | "praise"
|
|
27
|
+
export type Verdict = "edit" | "question" | "nit" | "praise" | "defer"
|
|
28
28
|
|
|
29
29
|
export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
|
|
30
30
|
|
|
@@ -49,6 +49,8 @@ export interface ReviewPolicy {
|
|
|
49
49
|
readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
|
|
50
50
|
readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
|
|
51
51
|
readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
|
|
52
|
+
/** Leave `defer` notes out of the round. */
|
|
53
|
+
readonly defer: (notes: readonly ReviewNote[]) => Promise<void>
|
|
52
54
|
/** Remove `file` at the end of a round at commit `round`. */
|
|
53
55
|
readonly close: (round: string) => Promise<void>
|
|
54
56
|
/** What a collecting turn reads. */
|
|
@@ -127,16 +129,16 @@ interface Round {
|
|
|
127
129
|
readonly unfolded: () => Promise<Finish>
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
/** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
|
|
131
|
-
const routeNotes = async (
|
|
132
|
+
/** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
|
|
133
|
+
export const routeNotes = async (
|
|
132
134
|
p: ReviewPolicy,
|
|
133
135
|
notes: readonly ReviewNote[],
|
|
134
136
|
r: Round,
|
|
135
137
|
): Promise<Finish> => {
|
|
136
138
|
const verdicts = await p.triage(notes)
|
|
137
139
|
const of = (v: Verdict): ReviewNote[] => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v)
|
|
138
|
-
const [edits, questions, nits] = [of("edit"), of("question"), of("nit")]
|
|
139
|
-
if (edits.length + questions.length + nits.length === 0) return r.unfolded()
|
|
140
|
+
const [edits, questions, nits, deferred] = [of("edit"), of("question"), of("nit"), of("defer")]
|
|
141
|
+
if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded()
|
|
140
142
|
|
|
141
143
|
let answeredAt: string | undefined
|
|
142
144
|
if (questions.length > 0) {
|
|
@@ -147,6 +149,7 @@ const routeNotes = async (
|
|
|
147
149
|
await p.fixNits(nits)
|
|
148
150
|
await p.keepGreen()
|
|
149
151
|
}
|
|
152
|
+
if (deferred.length > 0) await p.defer(deferred)
|
|
150
153
|
if (edits.length > 0) {
|
|
151
154
|
await r.close()
|
|
152
155
|
return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)))
|
|
@@ -155,7 +158,7 @@ const routeNotes = async (
|
|
|
155
158
|
await r.close()
|
|
156
159
|
return { next: "rereview", carry: answeredAt }
|
|
157
160
|
}
|
|
158
|
-
return { next: "await", reviewed: answeredAt
|
|
161
|
+
return { next: "await", reviewed: answeredAt ?? r.round }
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
/** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import type { Change } from "../flows/index.js"
|
|
3
|
+
import { assertionLines, removedAssertions } from "./scenarios.js"
|
|
4
|
+
|
|
5
|
+
const scenario = (name: string, ...steps: string[]): string[] => [
|
|
6
|
+
` Scenario: ${name}`,
|
|
7
|
+
...steps.map((s) => ` ${s}`),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
const feature = (...steps: string[]): string =>
|
|
11
|
+
["Feature: x", ...scenario("y", ...steps)].join("\n")
|
|
12
|
+
|
|
13
|
+
const modified = (path: string, before: string, after: string | undefined): Change => ({
|
|
14
|
+
path,
|
|
15
|
+
status: after === undefined ? "deleted" : "modified",
|
|
16
|
+
before,
|
|
17
|
+
after,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const lost = (path: string, name: string, ...lines: string[]) => ({
|
|
21
|
+
path,
|
|
22
|
+
lines: lines.map((line) => ({ scenario: `Scenario: ${name}`, line })),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("assertionLines", () => {
|
|
26
|
+
it("keeps Then lines and their And/But continuations, not Given/When ones", () => {
|
|
27
|
+
expect(
|
|
28
|
+
assertionLines(
|
|
29
|
+
feature(
|
|
30
|
+
"Given a",
|
|
31
|
+
"And b",
|
|
32
|
+
"When c",
|
|
33
|
+
"Then d",
|
|
34
|
+
"And e",
|
|
35
|
+
"| row |",
|
|
36
|
+
"But f",
|
|
37
|
+
"When g",
|
|
38
|
+
"And h",
|
|
39
|
+
),
|
|
40
|
+
).map((a) => a.line),
|
|
41
|
+
).toEqual(["Then d", "And e", "But f"])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("skips doc string content", () => {
|
|
45
|
+
expect(
|
|
46
|
+
assertionLines(feature("Then a file with:", ' """', " Then not a step", ' """')).map(
|
|
47
|
+
(a) => a.line,
|
|
48
|
+
),
|
|
49
|
+
).toEqual(["Then a file with:"])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("names the scenario each assertion sits in, through an Examples table", () => {
|
|
53
|
+
const source = [
|
|
54
|
+
"Feature: x",
|
|
55
|
+
...scenario("a", "Then one"),
|
|
56
|
+
" Scenario Outline: b",
|
|
57
|
+
" Then <two>",
|
|
58
|
+
" Examples:",
|
|
59
|
+
" | two |",
|
|
60
|
+
].join("\n")
|
|
61
|
+
expect(assertionLines(source)).toEqual([
|
|
62
|
+
{ scenario: "Scenario: a", line: "Then one" },
|
|
63
|
+
{ scenario: "Scenario Outline: b", line: "Then <two>" },
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("removedAssertions", () => {
|
|
69
|
+
it("reports a reworded assertion of an existing scenario", () => {
|
|
70
|
+
expect(
|
|
71
|
+
removedAssertions([
|
|
72
|
+
modified(
|
|
73
|
+
"a.feature",
|
|
74
|
+
feature("Given a", "Then it is shown"),
|
|
75
|
+
feature("Given a", "Then it is displayed"),
|
|
76
|
+
),
|
|
77
|
+
]),
|
|
78
|
+
).toEqual([lost("a.feature", "y", "Then it is shown")])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("ignores reworded Given and When steps and added assertions", () => {
|
|
82
|
+
expect(
|
|
83
|
+
removedAssertions([
|
|
84
|
+
modified(
|
|
85
|
+
"a.feature",
|
|
86
|
+
feature("Given a", "Then b"),
|
|
87
|
+
feature("Given z", "When y", "Then b", "And c"),
|
|
88
|
+
),
|
|
89
|
+
]),
|
|
90
|
+
).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("counts duplicates, so dropping one of two identical lines shows", () => {
|
|
94
|
+
expect(
|
|
95
|
+
removedAssertions([modified("a.feature", feature("Then b", "Then b"), feature("Then b"))]),
|
|
96
|
+
).toEqual([lost("a.feature", "y", "Then b")])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("reports an assertion that moved to another scenario", () => {
|
|
100
|
+
const before = ["Feature: x", ...scenario("a", "Then ok"), ...scenario("b", "When c")]
|
|
101
|
+
const after = ["Feature: x", ...scenario("a", "When d"), ...scenario("b", "When c", "Then ok")]
|
|
102
|
+
expect(removedAssertions([modified("a.feature", before.join("\n"), after.join("\n"))])).toEqual(
|
|
103
|
+
[lost("a.feature", "a", "Then ok")],
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("reports every assertion of a deleted file, none of an added one or a non-feature", () => {
|
|
108
|
+
expect(
|
|
109
|
+
removedAssertions([
|
|
110
|
+
modified("gone.feature", feature("Then b"), undefined),
|
|
111
|
+
{ path: "new.feature", status: "added", before: undefined, after: feature("Then c") },
|
|
112
|
+
modified("a.ts", "Then b", ""),
|
|
113
|
+
]),
|
|
114
|
+
).toEqual([lost("gone.feature", "y", "Then b")])
|
|
115
|
+
})
|
|
116
|
+
})
|