@pmelab/gtd 24.4.0 → 26.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 +15 -18
- package/dist/chunks/{reserved-B9pG93Fc.mjs → reserved-6pAgFGgB.mjs} +69 -125
- package/dist/gtd.bundle.mjs +28 -3
- package/dist/testkit.mjs +30 -5
- package/dist/workflow-kit.mjs +47 -90
- package/dist/workflows/feature.mjs +25 -47
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +135 -261
- package/package.json +2 -2
- package/src/cli/Cli.test.ts +25 -0
- package/src/cli/Cli.ts +12 -1
- package/src/flags/index.test.ts +67 -0
- package/src/flags/index.ts +39 -0
- package/src/replay/Replay.ts +2 -1
- package/src/workflow/vars.test.ts +16 -0
- package/src/workflow/vars.ts +4 -1
- 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 +0 -47
- package/src/workflows/feature/review.ts +6 -10
- package/src/workflows/index.ts +0 -2
- package/src/workflows/shared/health.test.ts +1 -21
- package/src/workflows/shared/health.ts +4 -39
- package/src/workflows/shared/options.ts +0 -1
- package/src/workflows/shared/steps.ts +16 -11
- package/src/workflows/shared/tail.test.ts +1 -41
- package/src/workflows/shared/tail.ts +14 -30
- package/src/workflows/shared/text.fixture.ts +1 -0
- package/src/workflows/shared/text.test.ts +23 -41
- package/src/workflows/shared/text.ts +54 -74
- package/src/workflows/shared/vars.ts +0 -1
- package/src/workflows/shared/scenarios.test.ts +0 -256
- package/src/workflows/shared/scenarios.ts +0 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "26.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
|
|
6
6
|
"bin": {
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"src/cli/",
|
|
70
70
|
"src/edge/",
|
|
71
71
|
"src/emit/",
|
|
72
|
+
"src/flags/",
|
|
72
73
|
"src/install/",
|
|
73
74
|
"src/platform/",
|
|
74
75
|
"src/replay/",
|
|
@@ -106,7 +107,6 @@
|
|
|
106
107
|
"watch": "tsdown --watch",
|
|
107
108
|
"bench:replay": "node scripts/bench-replay.mjs",
|
|
108
109
|
"test": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:workflows test:e2e:inmem test:e2e:live test:web analyze",
|
|
109
|
-
"test:fast": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:workflows test:web analyze",
|
|
110
110
|
"test:unit": "vitest run --project unit",
|
|
111
111
|
"test:workflows": "vitest run --project workflows",
|
|
112
112
|
"test:watch": "vitest",
|
package/src/cli/Cli.test.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
ToolCommands,
|
|
24
24
|
} from "./index.js"
|
|
25
25
|
import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
26
|
+
import { Host } from "../platform/index.js"
|
|
26
27
|
import { type CommandRequirements } from "./Cli.js"
|
|
27
28
|
import { InMemRepo, testLayers } from "../testkit/index.js"
|
|
28
29
|
import { CONFIG_SCAFFOLD } from "../install/index.js"
|
|
@@ -1164,6 +1165,30 @@ describe("runCli — layers() is never invoked for output/usage plans", () => {
|
|
|
1164
1165
|
})
|
|
1165
1166
|
})
|
|
1166
1167
|
|
|
1168
|
+
describe("runCli — a garbled GTD_FLAG_* value", () => {
|
|
1169
|
+
it("exits 1 naming the variable", async () => {
|
|
1170
|
+
const { io, captured } = capturingIo(
|
|
1171
|
+
() =>
|
|
1172
|
+
Layer.succeed(Host, {
|
|
1173
|
+
root: "/",
|
|
1174
|
+
home: "/",
|
|
1175
|
+
scratchDir: "/",
|
|
1176
|
+
realPath: (p: string) => Effect.succeed(p),
|
|
1177
|
+
env: { GTD_FLAG_BETA: "ture" },
|
|
1178
|
+
}) as unknown as Layer.Layer<CommandRequirements>,
|
|
1179
|
+
)
|
|
1180
|
+
await Effect.runPromise(
|
|
1181
|
+
runCli(["node", "gtd.js", "next"], {
|
|
1182
|
+
...io,
|
|
1183
|
+
flags: [{ name: "beta", owner: "pmelab", expiry: "2099-01-01", kind: "ops" }],
|
|
1184
|
+
}),
|
|
1185
|
+
)
|
|
1186
|
+
expect(captured().exitCode).toBe(1)
|
|
1187
|
+
expect(captured().stdout).toBe("")
|
|
1188
|
+
expect(captured().stderr).toContain("GTD_FLAG_BETA")
|
|
1189
|
+
})
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1167
1192
|
describe("runCli — exit codes", () => {
|
|
1168
1193
|
it("output plan: exit is never called (success) — --help and --version still exit 0", async () => {
|
|
1169
1194
|
const { io, captured } = capturingIo(throwingLayers)
|
package/src/cli/Cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NodeContext } from "@effect/platform-node"
|
|
2
2
|
import { Cause, Effect, Either, Layer } from "effect"
|
|
3
3
|
import {
|
|
4
|
+
GtdError,
|
|
4
5
|
GtdUsageError,
|
|
5
6
|
Narrator,
|
|
6
7
|
renderFailure,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
Workspace,
|
|
10
11
|
CommandRunner,
|
|
11
12
|
} from "../platform/index.js"
|
|
13
|
+
import { resolveFlags, type FlagDeclaration } from "../flags/index.js"
|
|
12
14
|
import { GTD_VERSION } from "../install/index.js"
|
|
13
15
|
import {
|
|
14
16
|
ConfigDiscovery,
|
|
@@ -1270,6 +1272,8 @@ export interface CliIo {
|
|
|
1270
1272
|
* writer when `false`.
|
|
1271
1273
|
*/
|
|
1272
1274
|
readonly layers: (verbose: boolean) => Layer.Layer<CommandRequirements>
|
|
1275
|
+
/** Test seam: the shipped registry is empty, so a garbled-value test cannot reach the failure path without injecting one. Drop it when the first real flag ships. */
|
|
1276
|
+
readonly flags?: readonly FlagDeclaration[]
|
|
1273
1277
|
}
|
|
1274
1278
|
|
|
1275
1279
|
// The one write-to-stderr primitive nodeCliIo uses for both `stderr` (errors)
|
|
@@ -1403,7 +1407,14 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
|
|
|
1403
1407
|
}
|
|
1404
1408
|
|
|
1405
1409
|
const out = bufferedArtifactOut(io)
|
|
1406
|
-
return
|
|
1410
|
+
return Effect.flatMap(Host, ({ env }) =>
|
|
1411
|
+
Effect.try({
|
|
1412
|
+
// Validate only: the result is discarded so a garbled value fails before any command runs.
|
|
1413
|
+
try: () => resolveFlags(env, io.flags),
|
|
1414
|
+
catch: (e) => new GtdError((e as Error).message),
|
|
1415
|
+
}),
|
|
1416
|
+
).pipe(
|
|
1417
|
+
Effect.flatMap(() => runCommand(plan.command, plan.json, out)),
|
|
1407
1418
|
// `flush()` fires here, on success, BEFORE `io.exit` — the one point
|
|
1408
1419
|
// where the whole buffered artifact is known-complete and safe to hand
|
|
1409
1420
|
// to stdout (a failure discards the buffer instead — see
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { FLAGS, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
|
|
3
|
+
|
|
4
|
+
const registry = [
|
|
5
|
+
{ name: "alpha", owner: "pmelab", expiry: "2020-01-01", kind: "release" },
|
|
6
|
+
{ name: "new_review", owner: "pmelab", expiry: "2099-01-01", kind: "ops" },
|
|
7
|
+
] as const satisfies readonly FlagDeclaration[]
|
|
8
|
+
|
|
9
|
+
describe("resolveFlags", () => {
|
|
10
|
+
it.each(["1", "true", "TRUE", "True"])("%s turns a flag on", (value) => {
|
|
11
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: value }, registry).alpha).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it.each(["0", "false", "FALSE"])("%s turns a flag off", (value) => {
|
|
15
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: value }, registry).alpha).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("reads unset and empty as off, with every declared flag a key", () => {
|
|
19
|
+
expect(resolveFlags({ GTD_FLAG_NEW_REVIEW: "" }, registry)).toEqual({
|
|
20
|
+
alpha: false,
|
|
21
|
+
new_review: false,
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it.each(["ture", "constructor", "__proto__", "toString"])(
|
|
26
|
+
"throws naming the variable, not the value %s",
|
|
27
|
+
(value) => {
|
|
28
|
+
expect(() => resolveFlags({ GTD_FLAG_NEW_REVIEW: value }, registry)).toThrow(
|
|
29
|
+
/^GTD_FLAG_NEW_REVIEW must be 1, 0, true or false$/,
|
|
30
|
+
)
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
it("reports the first garbled flag in registry order", () => {
|
|
35
|
+
expect(() => resolveFlags({ GTD_FLAG_NEW_REVIEW: "x", GTD_FLAG_ALPHA: "y" }, registry)).toThrow(
|
|
36
|
+
/^GTD_FLAG_ALPHA /,
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("ignores an undeclared GTD_FLAG_* key with a garbled value", () => {
|
|
41
|
+
expect(() => resolveFlags({ GTD_FLAG_NOPE: "ture" }, registry)).not.toThrow()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("resolves a flag past its expiry like any other", () => {
|
|
45
|
+
expect(registry[0].expiry < new Date().toISOString().slice(0, 10)).toBe(true)
|
|
46
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: "1" }, registry).alpha).toBe(true)
|
|
47
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: "0" }, registry).alpha).toBe(false)
|
|
48
|
+
expect(resolveFlags({}, registry).alpha).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("Flags<R> rejects a misspelt name", () => {
|
|
52
|
+
const flags: Flags<typeof registry> = resolveFlags({}, registry)
|
|
53
|
+
// @ts-expect-error misspelt flag name
|
|
54
|
+
void flags.alpah
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("FLAGS", () => {
|
|
59
|
+
it("declares settable, unique variable names", () => {
|
|
60
|
+
// Vacuous while FLAGS is empty; guards the first declared flag.
|
|
61
|
+
const names = (FLAGS as readonly FlagDeclaration[]).map(({ name }) => {
|
|
62
|
+
expect(name).toMatch(/^[a-z][a-z0-9_]*$/)
|
|
63
|
+
return name
|
|
64
|
+
})
|
|
65
|
+
expect(new Set(names).size).toBe(names.length)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A flag may change how a step runs, never which step comes next: flags are not recorded in
|
|
2
|
+
// history, so replay must not read them. No lint rule enforces this.
|
|
3
|
+
|
|
4
|
+
export type FlagKind = "release" | "ops" | "experiment"
|
|
5
|
+
|
|
6
|
+
export interface FlagDeclaration {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly owner: string
|
|
9
|
+
/** ISO `YYYY-MM-DD`; never enforced at runtime. */
|
|
10
|
+
readonly expiry: string
|
|
11
|
+
readonly kind: FlagKind
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// fallow-ignore-next-line unused-export
|
|
15
|
+
export const FLAGS = [] as const satisfies readonly FlagDeclaration[]
|
|
16
|
+
|
|
17
|
+
/** A misspelt name is a compile error. */
|
|
18
|
+
export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
19
|
+
readonly [N in R[number]["name"]]: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const BOOL: Record<string, boolean> = { "": false, "0": false, false: false, "1": true, true: true }
|
|
23
|
+
|
|
24
|
+
export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
|
|
25
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
26
|
+
registry: R = FLAGS as unknown as R,
|
|
27
|
+
): Flags<R> => {
|
|
28
|
+
const out: Record<string, boolean> = {}
|
|
29
|
+
for (const { name } of registry) {
|
|
30
|
+
const variable = `GTD_FLAG_${name.toUpperCase()}`
|
|
31
|
+
const raw = env[variable] ?? ""
|
|
32
|
+
const key = raw.toLowerCase()
|
|
33
|
+
if (!Object.hasOwn(BOOL, key)) {
|
|
34
|
+
throw new Error(`${variable} must be 1, 0, true or false`)
|
|
35
|
+
}
|
|
36
|
+
out[name] = BOOL[key]!
|
|
37
|
+
}
|
|
38
|
+
return out as Flags<R>
|
|
39
|
+
}
|
package/src/replay/Replay.ts
CHANGED
|
@@ -260,7 +260,8 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
260
260
|
const treeAt = (hash: string): TreeView | undefined =>
|
|
261
261
|
hash === input.episode.base.hash
|
|
262
262
|
? input.episode.base.tree
|
|
263
|
-
: commits.find((c) => c.hash === hash)?.tree
|
|
263
|
+
: (commits.find((c) => c.hash === hash)?.tree ??
|
|
264
|
+
(hash === input.start ? input.startTree : undefined))
|
|
264
265
|
|
|
265
266
|
let cursor = 0
|
|
266
267
|
let pendingUsed = false
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { resolveVars } from "./vars.js"
|
|
3
|
+
|
|
4
|
+
describe("resolveVars — GTD_FLAG_* belongs to feature flags", () => {
|
|
5
|
+
it.each(["flag_x", "FLAG_X", "Flag_x"])("%s keeps its default with GTD_FLAG_X set", (name) => {
|
|
6
|
+
expect(resolveVars({ [name]: "off" }, {}, { GTD_FLAG_X: "on" })).toEqual({ [name]: "off" })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it.each([
|
|
10
|
+
["flags", "GTD_FLAGS"],
|
|
11
|
+
["flagship", "GTD_FLAGSHIP"],
|
|
12
|
+
["my_flag_x", "GTD_MY_FLAG_X"],
|
|
13
|
+
])("%s still takes %s", (name, key) => {
|
|
14
|
+
expect(resolveVars({ [name]: "off" }, {}, { [key]: "on" })).toEqual({ [name]: "on" })
|
|
15
|
+
})
|
|
16
|
+
})
|
package/src/workflow/vars.ts
CHANGED
|
@@ -13,7 +13,10 @@ export const resolveVars = (
|
|
|
13
13
|
): Record<string, string> => {
|
|
14
14
|
const merged = { ...workflowVars, ...entryVars }
|
|
15
15
|
for (const name of Object.keys(merged)) {
|
|
16
|
-
const
|
|
16
|
+
const key = PREFIX + name.toUpperCase()
|
|
17
|
+
// GTD_FLAG_* belongs to feature flags; a flag_* setting loses its env override.
|
|
18
|
+
if (key.startsWith("GTD_FLAG_")) continue
|
|
19
|
+
const value = env[key]
|
|
17
20
|
if (value !== undefined) merged[name] = value
|
|
18
21
|
}
|
|
19
22
|
return merged
|
|
@@ -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
|
+
})
|
|
@@ -1,130 +1,70 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Change } from "../flows/index.js"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const DOC_STRING = /^(\s*)("""|```)/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
|
|
13
|
-
* dropped — except inside a doc string, whose content is test input: kept
|
|
14
|
-
* whole, indented relative to its opening delimiter (as Gherkin reads it).
|
|
15
|
-
*/
|
|
16
|
-
export const scenarioText = (source: string): string => {
|
|
17
|
-
const out: string[] = []
|
|
18
|
-
let doc: { readonly indent: number; readonly fence: string } | undefined
|
|
19
|
-
for (const line of source.split("\n")) {
|
|
20
|
-
const delimiter = DOC_STRING.exec(line)
|
|
21
|
-
if (doc === undefined && delimiter !== null) {
|
|
22
|
-
doc = { indent: delimiter[1]!.length, fence: delimiter[2]! }
|
|
23
|
-
out.push(line.trim())
|
|
24
|
-
} else if (doc !== undefined && line.trim().startsWith(doc.fence)) {
|
|
25
|
-
doc = undefined
|
|
26
|
-
out.push(line.trim())
|
|
27
|
-
} else if (doc !== undefined) {
|
|
28
|
-
const lead = line.length - line.trimStart().length
|
|
29
|
-
out.push(line.slice(Math.min(lead, doc.indent)).trimEnd())
|
|
30
|
-
} else {
|
|
31
|
-
const trimmed = line.trim()
|
|
32
|
-
if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed)
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return out.join("\n")
|
|
36
|
-
}
|
|
3
|
+
const DOC_STRING = /^("""|```)/
|
|
4
|
+
const THEN = /^Then\s/
|
|
5
|
+
const CONTINUATION = /^(And|But|\*)\s/
|
|
6
|
+
const HEADING = /^(Feature|Rule|Background|Scenario|Scenario Outline|Scenario Template|Example):/
|
|
37
7
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
8
|
+
export interface Assertion {
|
|
9
|
+
/** The trimmed heading line of the scenario (or background) it sits in. */
|
|
10
|
+
readonly scenario: string
|
|
11
|
+
readonly line: string
|
|
41
12
|
}
|
|
42
13
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (text !== undefined) texts[path] = text
|
|
14
|
+
/** Trimmed lines outside doc strings, whose content is test input, not steps. */
|
|
15
|
+
const stepLines = (source: string): string[] => {
|
|
16
|
+
const out: string[] = []
|
|
17
|
+
let fence: string | undefined
|
|
18
|
+
for (const trimmed of source.split("\n").map((l) => l.trim())) {
|
|
19
|
+
const opening = fence === undefined ? DOC_STRING.exec(trimmed)?.[1] : undefined
|
|
20
|
+
if (opening !== undefined) fence = opening
|
|
21
|
+
else if (fence === undefined) out.push(trimmed)
|
|
22
|
+
else if (trimmed.startsWith(fence)) fence = undefined
|
|
53
23
|
}
|
|
54
|
-
return
|
|
24
|
+
return out
|
|
55
25
|
}
|
|
56
26
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Object.entries(frozen.texts)
|
|
60
|
-
.filter(([path, text]) => textOf(path) !== text)
|
|
61
|
-
.map(([path]) => path)
|
|
62
|
-
|
|
63
|
-
// Blank lines survive only inside doc strings, where they are wording.
|
|
64
|
-
const lines = (text: string | undefined): string[] =>
|
|
65
|
-
text === undefined || text === "" ? [] : text.split("\n")
|
|
66
|
-
|
|
67
|
-
/** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
|
|
68
|
-
const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
|
|
69
|
-
const table = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0))
|
|
70
|
-
for (let i = a.length - 1; i >= 0; i--) {
|
|
71
|
-
for (let j = b.length - 1; j >= 0; j--) {
|
|
72
|
-
table[i]![j] =
|
|
73
|
-
a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return table
|
|
77
|
-
}
|
|
27
|
+
const neutral = (line: string): boolean =>
|
|
28
|
+
line === "" || line.startsWith("#") || line.startsWith("|")
|
|
78
29
|
|
|
79
|
-
/**
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
let
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) out.push(`- ${a[i++]}`)
|
|
90
|
-
else out.push(`+ ${b[j++]}`)
|
|
30
|
+
/** Trimmed `Then` step lines, an `And`/`But`/`*` continuing one included; doc strings skipped. */
|
|
31
|
+
export const assertionLines = (source: string): readonly Assertion[] => {
|
|
32
|
+
const out: Assertion[] = []
|
|
33
|
+
let inThen = false
|
|
34
|
+
let scenario = ""
|
|
35
|
+
for (const line of stepLines(source)) {
|
|
36
|
+
if (HEADING.test(line)) scenario = line
|
|
37
|
+
if (THEN.test(line)) inThen = true
|
|
38
|
+
else if (!CONTINUATION.test(line) && !neutral(line)) inThen = false
|
|
39
|
+
if (inThen && !neutral(line)) out.push({ scenario, line })
|
|
91
40
|
}
|
|
92
|
-
return
|
|
41
|
+
return out
|
|
93
42
|
}
|
|
94
43
|
|
|
95
|
-
export interface
|
|
44
|
+
export interface RemovedAssertions {
|
|
96
45
|
readonly path: string
|
|
97
|
-
|
|
98
|
-
readonly lines: readonly string[]
|
|
46
|
+
readonly lines: readonly Assertion[]
|
|
99
47
|
}
|
|
100
48
|
|
|
101
|
-
|
|
102
|
-
path,
|
|
103
|
-
lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path))),
|
|
104
|
-
})
|
|
49
|
+
const key = (a: Assertion): string => `${a.scenario}\n${a.line}`
|
|
105
50
|
|
|
106
51
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
52
|
+
* Assertions a `.feature` file that existed before `changes` lost — the one
|
|
53
|
+
* way rewording a scenario can weaken it. Matched within each scenario and
|
|
54
|
+
* counted, so a line moving to another scenario, or one of two identical
|
|
55
|
+
* `Then`s dropped, still shows. A renamed scenario reports all of its own.
|
|
110
56
|
*/
|
|
111
|
-
export const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const text = textOf(path)
|
|
126
|
-
if (text === undefined) delete frozen.texts[path]
|
|
127
|
-
else frozen.texts[path] = text
|
|
128
|
-
}
|
|
129
|
-
frozen.at = head()
|
|
130
|
-
}
|
|
57
|
+
export const removedAssertions = (changes: readonly Change[]): readonly RemovedAssertions[] =>
|
|
58
|
+
changes
|
|
59
|
+
.filter((c) => c.path.endsWith(".feature") && c.before !== undefined)
|
|
60
|
+
.map((c) => {
|
|
61
|
+
const left = new Map<string, number>()
|
|
62
|
+
for (const a of assertionLines(c.after ?? "")) left.set(key(a), (left.get(key(a)) ?? 0) + 1)
|
|
63
|
+
const lines = assertionLines(c.before ?? "").filter((a) => {
|
|
64
|
+
const n = left.get(key(a)) ?? 0
|
|
65
|
+
left.set(key(a), n - 1)
|
|
66
|
+
return n <= 0
|
|
67
|
+
})
|
|
68
|
+
return { path: c.path, lines }
|
|
69
|
+
})
|
|
70
|
+
.filter((r) => r.lines.length > 0)
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
} from "../../flows/index.js"
|
|
11
11
|
import { gate } from "../shared/health.js"
|
|
12
12
|
import { shipped } from "../shared/options.js"
|
|
13
|
-
import type { FrozenScenarios } from "../shared/scenarios.js"
|
|
14
13
|
import { FEEDBACK, REVIEW } from "../shared/steps.js"
|
|
15
14
|
import { refusePendingHandOff } from "../shared/tail.js"
|
|
16
15
|
import * as t from "../shared/text.js"
|
|
@@ -53,13 +52,11 @@ export const reUnwind = async (
|
|
|
53
52
|
/** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
|
|
54
53
|
export const planAndBuild = async (firstBase: string): Promise<void> => {
|
|
55
54
|
let base = firstBase
|
|
56
|
-
let carried: FrozenScenarios | undefined
|
|
57
55
|
for (;;) {
|
|
58
56
|
await design(base)
|
|
59
57
|
await architecture()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const outcome = await buildTail(false, base, built)
|
|
58
|
+
await packages()
|
|
59
|
+
const outcome = await buildTail(false, base, true)
|
|
63
60
|
if (outcome.verdict === "signoff") return
|
|
64
61
|
await reUnwind(outcome)
|
|
65
62
|
base = outcome.base
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest"
|
|
2
2
|
import { installContext, type Change, type StepRequest } from "../../flows/index.js"
|
|
3
|
-
import { declaredTests, packages
|
|
3
|
+
import { declaredTests, packages } from "./packages.js"
|
|
4
4
|
import { fixtureContext } from "../shared/text.fixture.js"
|
|
5
5
|
|
|
6
6
|
afterEach(() => installContext(undefined))
|
|
@@ -41,51 +41,34 @@ describe("declaredTests", () => {
|
|
|
41
41
|
})
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
-
describe("packages
|
|
45
|
-
it("
|
|
46
|
-
const queue = [
|
|
47
|
-
const
|
|
48
|
-
let steps = 0
|
|
49
|
-
const touch = (path: string, status: Change["status"]): void => {
|
|
50
|
-
touches.push({ at: steps, change: { path, status, before: undefined, after: "x" } })
|
|
51
|
-
}
|
|
52
|
-
const effects: Record<string, () => void> = {
|
|
53
|
-
building: () => {
|
|
54
|
-
if (queue[0] === SCENARIO_PACKAGE) {
|
|
55
|
-
touch("tests/e2e/a.feature", "added")
|
|
56
|
-
touch(".gtd/packages/00-e2e-scenarios.md", "modified")
|
|
57
|
-
} else {
|
|
58
|
-
touch("src/impl.ts", "added")
|
|
59
|
-
touch("tests/e2e/b.feature", "added")
|
|
60
|
-
touch("tests/e2e/a.feature", "modified")
|
|
61
|
-
}
|
|
62
|
-
},
|
|
63
|
-
closing: () => void queue.shift(),
|
|
64
|
-
}
|
|
44
|
+
describe("packages", () => {
|
|
45
|
+
it("closes every queued package in name order, the plan with the last", async () => {
|
|
46
|
+
const queue = [".gtd/packages/02-b.md", ".gtd/packages/01-a.md"]
|
|
47
|
+
const closings: string[] = []
|
|
65
48
|
installContext(
|
|
66
49
|
fixtureContext(
|
|
67
50
|
{},
|
|
68
51
|
{
|
|
69
52
|
step: (request: StepRequest) => {
|
|
70
|
-
|
|
71
|
-
|
|
53
|
+
if (request.kind === "run" && request.name === "closing") {
|
|
54
|
+
closings.push(request.body)
|
|
55
|
+
queue.splice(queue.indexOf([...queue].sort()[0]!), 1)
|
|
56
|
+
}
|
|
72
57
|
return Promise.resolve()
|
|
73
58
|
},
|
|
74
59
|
pushScope: () => undefined,
|
|
75
60
|
popScope: () => undefined,
|
|
76
|
-
refuse: (message: string): never => {
|
|
77
|
-
throw new Error(message)
|
|
78
|
-
},
|
|
79
61
|
glob: () => [...queue],
|
|
80
|
-
changesSince: (
|
|
81
|
-
|
|
82
|
-
head: () => `c${steps}`,
|
|
62
|
+
changesSince: (): readonly Change[] => [],
|
|
63
|
+
head: () => "c",
|
|
83
64
|
},
|
|
84
65
|
),
|
|
85
66
|
)
|
|
86
|
-
|
|
87
|
-
expect(
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
67
|
+
await packages()
|
|
68
|
+
expect(closings).toHaveLength(2)
|
|
69
|
+
expect(closings[0]).toContain("01-a.md")
|
|
70
|
+
expect(closings[0]).not.toContain("ARCHITECTURE")
|
|
71
|
+
expect(closings[1]).toContain("02-b.md")
|
|
72
|
+
expect(closings[1]).toContain(".gtd/ARCHITECTURE.md")
|
|
90
73
|
})
|
|
91
74
|
})
|