@newt-kit/plan 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/README.md +35 -2
- package/cli/commands.ts +184 -0
- package/index.ts +14 -0
- package/map/app-map.ts +69 -0
- package/map/read-map.ts +453 -0
- package/package.json +53 -4
- package/plan/check.ts +154 -0
- package/plan/compare.ts +127 -0
- package/plan/diff.ts +50 -0
- package/plan/markdown.ts +50 -0
- package/plan/plan.schema.ts +75 -0
- package/plan/resolve.ts +314 -0
- package/plan/review.ts +34 -0
- package/render/browser.ts +16 -0
- package/render/page-data.ts +89 -0
- package/render/render.ts +38 -0
- package/render/template.html +888 -0
- package/skills/newt-plan/SKILL.md +88 -0
- package/skills/newt-review/SKILL.md +25 -0
package/plan/compare.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { AppMap } from '../map/app-map'
|
|
2
|
+
import type { Plan } from './plan.schema'
|
|
3
|
+
import { piecesNamed } from './resolve'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* - `done`: the branch does what the plan says.
|
|
7
|
+
* - `missing`: the plan says it, the branch does not.
|
|
8
|
+
* - `different`: the branch does something else with it (removes what was to change).
|
|
9
|
+
* - `unplanned`: the branch does it, the plan does not say it.
|
|
10
|
+
*/
|
|
11
|
+
export type Verdict = 'done' | 'missing' | 'different' | 'unplanned'
|
|
12
|
+
|
|
13
|
+
export type Check = {
|
|
14
|
+
/** A module's, a piece's id, or a relation as `<from> <kind> <to>`. */
|
|
15
|
+
id: string
|
|
16
|
+
what: 'module' | 'piece' | 'relation'
|
|
17
|
+
planned?: 'new' | 'modified' | 'removed'
|
|
18
|
+
found?: 'new' | 'modified' | 'removed'
|
|
19
|
+
verdict: Verdict
|
|
20
|
+
/** Whether it fails the review: what is missing or different, and a module or a piece unplanned. */
|
|
21
|
+
failing: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type StepState = 'done' | 'partial' | 'notStarted' | 'nothingToCheck'
|
|
25
|
+
/** A step and how many of the things it names are done. */
|
|
26
|
+
export type StepCheck = { title: string, state: StepState, done: number, of: number }
|
|
27
|
+
|
|
28
|
+
export type Comparison = { checks: Check[], steps: StepCheck[], failing: number }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A plan held against what a branch did (`diffMaps(base, head)`): each thing it says, done
|
|
32
|
+
* or not, what the branch did that it does not say, and each step done when its pieces are.
|
|
33
|
+
*/
|
|
34
|
+
export function comparePlan(plan: Plan, found: Plan, head: AppMap): Comparison {
|
|
35
|
+
const checks: Check[] = []
|
|
36
|
+
const foundModules = new Map(found.modules.map(module => [module.id, module.status]))
|
|
37
|
+
const foundPieces = new Map(found.pieces.map(piece => [piece.id, piece.status]))
|
|
38
|
+
const keyOf = (relation: { kind: string, from: string, to: string }) => `${relation.from} ${relation.kind} ${relation.to}`
|
|
39
|
+
const foundRelations = new Map(found.relations.map(relation => [keyOf(relation), relation.status]))
|
|
40
|
+
const headPieces = new Map(head.modules.flatMap(module => module.pieces.map(piece => [piece.id, piece] as const)))
|
|
41
|
+
// A use case whose calls or emits changed, or that an event now triggers, is modified too.
|
|
42
|
+
const touched = new Set(found.relations.flatMap(relation => relation.kind === 'triggers' ? [relation.to] : [relation.from]))
|
|
43
|
+
const foundStatusOf = (id: string) => foundPieces.get(id) ?? (touched.has(id) && headPieces.has(id) ? 'modified' as const : undefined)
|
|
44
|
+
|
|
45
|
+
const verdictOf = (planned: Check['planned'], status: Check['found']): Verdict => {
|
|
46
|
+
if (planned === status) {
|
|
47
|
+
return 'done'
|
|
48
|
+
}
|
|
49
|
+
if (planned === 'modified' && status === 'new') {
|
|
50
|
+
return 'done'
|
|
51
|
+
}
|
|
52
|
+
if (status === undefined) {
|
|
53
|
+
return 'missing'
|
|
54
|
+
}
|
|
55
|
+
return 'different'
|
|
56
|
+
}
|
|
57
|
+
const add = (check: Omit<Check, 'failing'>) => {
|
|
58
|
+
const failing = check.verdict === 'missing' || check.verdict === 'different' || (check.verdict === 'unplanned' && check.what !== 'relation' && check.found !== 'modified')
|
|
59
|
+
checks.push({ ...check, failing })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const plannedModules = new Set<string>()
|
|
63
|
+
for (const module of plan.modules) {
|
|
64
|
+
if (!module.status) {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
plannedModules.add(module.id)
|
|
68
|
+
add({ id: module.id, what: 'module', planned: module.status, ...found_(foundModules.get(module.id)), verdict: verdictOf(module.status, foundModules.get(module.id)) })
|
|
69
|
+
}
|
|
70
|
+
for (const [id, status] of foundModules) {
|
|
71
|
+
if (!plannedModules.has(id)) {
|
|
72
|
+
add({ id, what: 'module', ...found_(status), verdict: 'unplanned' })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const plannedPieces = new Set<string>()
|
|
77
|
+
for (const piece of plan.pieces) {
|
|
78
|
+
if (!piece.status) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
plannedPieces.add(piece.id)
|
|
82
|
+
const status = foundStatusOf(piece.id)
|
|
83
|
+
const kindChanged = piece.status === 'new' && headPieces.has(piece.id) && headPieces.get(piece.id)!.kind !== piece.kind
|
|
84
|
+
add({ id: piece.id, what: 'piece', planned: piece.status, ...found_(status), verdict: kindChanged ? 'different' : verdictOf(piece.status, status) })
|
|
85
|
+
}
|
|
86
|
+
for (const [id, status] of foundPieces) {
|
|
87
|
+
if (!plannedPieces.has(id)) {
|
|
88
|
+
add({ id, what: 'piece', ...found_(status), verdict: 'unplanned' })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const plannedRelations = new Set<string>()
|
|
93
|
+
for (const relation of plan.relations) {
|
|
94
|
+
const key = keyOf(relation)
|
|
95
|
+
plannedRelations.add(key)
|
|
96
|
+
add({ id: key, what: 'relation', planned: relation.status, ...found_(foundRelations.get(key)), verdict: verdictOf(relation.status, foundRelations.get(key)) })
|
|
97
|
+
}
|
|
98
|
+
for (const [key, status] of foundRelations) {
|
|
99
|
+
if (!plannedRelations.has(key)) {
|
|
100
|
+
add({ id: key, what: 'relation', found: status, verdict: 'unplanned' })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A step is done when the pieces it names are, and the relations between them.
|
|
105
|
+
const planned = checks.filter(check => check.planned !== undefined)
|
|
106
|
+
const steps = plan.steps.map(step => {
|
|
107
|
+
const named = new Set(step.focus ?? piecesNamed(`${step.title} ${step.text ?? ''}`))
|
|
108
|
+
const own = planned.filter(check => {
|
|
109
|
+
if (check.what === 'piece') {
|
|
110
|
+
return named.has(check.id)
|
|
111
|
+
}
|
|
112
|
+
if (check.what !== 'relation') {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
const [from, , to] = check.id.split(' ')
|
|
116
|
+
return named.has(from!) && named.has(to!)
|
|
117
|
+
})
|
|
118
|
+
const done = own.filter(check => check.verdict === 'done').length
|
|
119
|
+
const state: StepState = own.length === 0 ? 'nothingToCheck' : done === own.length ? 'done' : done === 0 ? 'notStarted' : 'partial'
|
|
120
|
+
return { title: step.title, state, done, of: own.length }
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return { checks, steps, failing: checks.filter(check => check.failing).length }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const found_ = (status: Check['found']) => status === undefined ? {} : { found: status }
|
|
127
|
+
|
package/plan/diff.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { moduleOf, type AppMap, type MapPiece } from '../map/app-map'
|
|
2
|
+
import type { Plan } from './plan.schema'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What `head` (a branch) adds to, changes in and takes away from `base` (main), said as a
|
|
6
|
+
* plan: laid on `base` by `resolvePlan`, it draws the change. A piece is modified when its
|
|
7
|
+
* code is (its hash), or a schedule's cron; one whose calls or emits change is found
|
|
8
|
+
* modified by `resolvePlan` too.
|
|
9
|
+
*/
|
|
10
|
+
export function diffMaps(base: AppMap, head: AppMap, words: { title: string, subtitle?: string, language?: Plan['language'] }): Plan {
|
|
11
|
+
const baseContexts = new Set(base.contexts.map(context => context.name))
|
|
12
|
+
const headContexts = new Set(head.contexts.map(context => context.name))
|
|
13
|
+
const baseModules = new Map(base.modules.map(module => [module.id, module]))
|
|
14
|
+
const headModules = new Map(head.modules.map(module => [module.id, module]))
|
|
15
|
+
const piecesOf = (map: AppMap) => new Map(map.modules.flatMap(module => module.pieces.map(piece => [piece.id, piece] as const)))
|
|
16
|
+
const basePieces = piecesOf(base), headPieces = piecesOf(head)
|
|
17
|
+
const keyOf = (relation: { kind: string, from: string, to: string }) => `${relation.kind} ${relation.from} ${relation.to}`
|
|
18
|
+
const baseRelations = new Map(base.relations.map(relation => [keyOf(relation), relation]))
|
|
19
|
+
const headRelations = new Map(head.relations.map(relation => [keyOf(relation), relation]))
|
|
20
|
+
|
|
21
|
+
const changed = (before: MapPiece, after: MapPiece) => before.kind !== after.kind || before.cron !== after.cron || before.hash !== after.hash
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
version: 1,
|
|
25
|
+
language: words.language ?? 'en',
|
|
26
|
+
title: words.title,
|
|
27
|
+
...(words.subtitle ? { subtitle: words.subtitle } : {}),
|
|
28
|
+
contexts: [
|
|
29
|
+
...[...headContexts].filter(name => !baseContexts.has(name)).map(name => ({ name, status: 'new' as const })),
|
|
30
|
+
...[...baseContexts].filter(name => !headContexts.has(name)).map(name => ({ name, status: 'removed' as const })),
|
|
31
|
+
],
|
|
32
|
+
modules: [
|
|
33
|
+
...[...headModules.values()].filter(module => !baseModules.has(module.id)).map(module => ({ id: module.id, status: 'new' as const, ...(module.port ? { port: true } : {}) })),
|
|
34
|
+
...[...baseModules.values()].filter(module => !headModules.has(module.id)).map(module => ({ id: module.id, status: 'removed' as const })),
|
|
35
|
+
],
|
|
36
|
+
pieces: [
|
|
37
|
+
...[...headPieces.values()].filter(piece => !basePieces.has(piece.id)).map(piece => ({ id: piece.id, kind: piece.kind, status: 'new' as const, ...(piece.cron ? { cron: piece.cron } : {}) })),
|
|
38
|
+
// A piece of a module taken away goes with its module.
|
|
39
|
+
...[...basePieces.values()].filter(piece => !headPieces.has(piece.id) && headModules.has(moduleOf(piece.id))).map(piece => ({ id: piece.id, kind: piece.kind, status: 'removed' as const })),
|
|
40
|
+
...[...headPieces.values()].filter(piece => basePieces.has(piece.id) && changed(basePieces.get(piece.id)!, piece)).map(piece => ({ id: piece.id, kind: basePieces.get(piece.id)!.kind, status: 'modified' as const })),
|
|
41
|
+
],
|
|
42
|
+
relations: [
|
|
43
|
+
...[...headRelations.values()].filter(relation => !baseRelations.has(keyOf(relation))).map(({ kind, from, to }) => ({ kind, from, to, status: 'new' as const })),
|
|
44
|
+
// What a removed piece was in goes with it.
|
|
45
|
+
...[...baseRelations.values()].filter(relation => !headRelations.has(keyOf(relation)) && headPieces.has(relation.from) && headPieces.has(relation.to)).map(({ kind, from, to }) => ({ kind, from, to, status: 'removed' as const })),
|
|
46
|
+
],
|
|
47
|
+
steps: [],
|
|
48
|
+
rules: [],
|
|
49
|
+
}
|
|
50
|
+
}
|
package/plan/markdown.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Check } from './compare'
|
|
2
|
+
import type { Review } from './review'
|
|
3
|
+
|
|
4
|
+
const WORDS = {
|
|
5
|
+
en: {
|
|
6
|
+
title: (title: string) => `## Review: ${title}`, nothing: 'The branch changes no module, use case, event or schedule.',
|
|
7
|
+
changes: '### What the branch changes', steps: '### The plan\'s steps', checks: '### Against the plan', rules: '### Rules',
|
|
8
|
+
verdict: { done: '✓ done', missing: '✗ missing', different: '≠ different', unplanned: '+ not planned' },
|
|
9
|
+
state: { done: 'done', partial: 'half done', notStarted: 'not started', nothingToCheck: 'nothing to check' },
|
|
10
|
+
status: { new: 'new', modified: 'modified', removed: 'removed' },
|
|
11
|
+
failing: (n: number) => n ? `**${n} to look at.**` : '**The branch does what the plan says.**',
|
|
12
|
+
},
|
|
13
|
+
es: {
|
|
14
|
+
title: (title: string) => `## Revisión: ${title}`, nothing: 'La rama no cambia ningún módulo, caso de uso, evento ni tarea programada.',
|
|
15
|
+
changes: '### Lo que cambia la rama', steps: '### Los pasos del plan', checks: '### Frente al plan', rules: '### Reglas',
|
|
16
|
+
verdict: { done: '✓ cumplido', missing: '✗ falta', different: '≠ distinto', unplanned: '+ no planeado' },
|
|
17
|
+
state: { done: 'completo', partial: 'a medias', notStarted: 'sin empezar', nothingToCheck: 'nada que comprobar' },
|
|
18
|
+
status: { new: 'nuevo', modified: 'modificado', removed: 'eliminado' },
|
|
19
|
+
failing: (n: number) => n ? `**${n} cosa${n === 1 ? '' : 's'} que mirar.**` : '**La rama hace lo que dice el plan.**',
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A review as Markdown, for a pull request's comment. */
|
|
24
|
+
export function reviewMarkdown({ resolved, comparison }: Review): string {
|
|
25
|
+
const words = WORDS[resolved.language]
|
|
26
|
+
const lines = [words.title(resolved.title), '']
|
|
27
|
+
const changed = resolved.modules.flatMap(module => module.pieces).filter(piece => piece.status === 'new' || piece.status === 'modified' || piece.status === 'removed')
|
|
28
|
+
if (!comparison) {
|
|
29
|
+
lines.push(words.changes, '')
|
|
30
|
+
if (!changed.length) {
|
|
31
|
+
lines.push(words.nothing)
|
|
32
|
+
}
|
|
33
|
+
for (const piece of changed) {
|
|
34
|
+
lines.push(`- \`${piece.id}\` ${words.status[piece.status as 'new' | 'modified' | 'removed']}`)
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
lines.push(words.failing(comparison.failing), '', words.steps, '')
|
|
38
|
+
comparison.steps.forEach((step, at) => lines.push(`${at + 1}. ${step.title}: ${words.state[step.state]}${step.state === 'partial' ? ` (${step.done}/${step.of})` : ''}`))
|
|
39
|
+
lines.push('', words.checks, '')
|
|
40
|
+
const order = (check: Check) => Number(!check.failing)
|
|
41
|
+
for (const check of [...comparison.checks].sort((a, b) => order(a) - order(b))) {
|
|
42
|
+
lines.push(`- ${words.verdict[check.verdict]}: \`${check.id}\``)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
lines.push('', words.rules, '')
|
|
46
|
+
for (const rule of resolved.rules) {
|
|
47
|
+
lines.push(`- ${rule.ok ? '✓' : '!'} ${rule.text}`)
|
|
48
|
+
}
|
|
49
|
+
return `${lines.join('\n')}\n`
|
|
50
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A plan: what a feature does to an application's map, and how to tell it. Only what it
|
|
5
|
+
* changes is written; everything else is the map's, and whatever a change touches but
|
|
6
|
+
* does not change (a use case it calls, an event it listens to) is found, not written.
|
|
7
|
+
*
|
|
8
|
+
* Written by hand or by an agent, read by `resolvePlan`, which refuses one that speaks of
|
|
9
|
+
* a piece the map has not, or adds one it has.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** `boards/card` */
|
|
13
|
+
export const ModuleIdSchema = z.string().regex(/^[a-z][\w-]*\/[a-zA-Z][\w]*$/, 'A module id is <context>/<module>: boards/card')
|
|
14
|
+
/** `boards/card.setDueDate` */
|
|
15
|
+
export const PieceIdSchema = z.string().regex(/^[a-z][\w-]*\/[a-zA-Z][\w]*\.[a-zA-Z][\w]*$/, 'A piece id is <context>/<module>.<name>: boards/card.setDueDate')
|
|
16
|
+
|
|
17
|
+
export const PieceKindSchema = z.enum(['useCase', 'event', 'schedule'])
|
|
18
|
+
export const RelationKindSchema = z.enum(['emits', 'triggers', 'calls'])
|
|
19
|
+
|
|
20
|
+
const why = z.string().min(1).optional()
|
|
21
|
+
|
|
22
|
+
export const PlanSchema = z.strictObject({
|
|
23
|
+
version: z.literal(1),
|
|
24
|
+
/** The language of the page's own words; the plan's are in whatever it is written in. */
|
|
25
|
+
language: z.enum(['en', 'es']).default('en'),
|
|
26
|
+
title: z.string().min(1),
|
|
27
|
+
subtitle: z.string().min(1).optional(),
|
|
28
|
+
/** Contexts it adds or takes away. */
|
|
29
|
+
contexts: z.array(z.strictObject({ name: z.string().regex(/^[a-z][\w-]*$/), status: z.enum(['new', 'removed']) })).default([]),
|
|
30
|
+
/** Modules it adds or takes away; one with no status only says why it matters. */
|
|
31
|
+
modules: z.array(z.strictObject({
|
|
32
|
+
id: ModuleIdSchema,
|
|
33
|
+
status: z.enum(['new', 'removed']).optional(),
|
|
34
|
+
/** A port: something its modules call that is no module (a mailer, a search index). */
|
|
35
|
+
port: z.boolean().optional(),
|
|
36
|
+
why,
|
|
37
|
+
})).default([]),
|
|
38
|
+
/** Use cases, events and schedules it adds, changes or takes away; one with no status only says why it matters. */
|
|
39
|
+
pieces: z.array(z.strictObject({
|
|
40
|
+
id: PieceIdSchema,
|
|
41
|
+
kind: PieceKindSchema,
|
|
42
|
+
status: z.enum(['new', 'modified', 'removed']).optional(),
|
|
43
|
+
/** A new schedule's cron expression. */
|
|
44
|
+
cron: z.string().min(1).optional(),
|
|
45
|
+
why,
|
|
46
|
+
})).default([]),
|
|
47
|
+
/**
|
|
48
|
+
* How pieces meet, added or taken away: a use case `emits` an event of its module; an
|
|
49
|
+
* event or a schedule `triggers` a use case (a subscription, whose queue is found); a use
|
|
50
|
+
* case `calls` a use case of another module or of a port.
|
|
51
|
+
*/
|
|
52
|
+
relations: z.array(z.strictObject({
|
|
53
|
+
kind: RelationKindSchema,
|
|
54
|
+
from: PieceIdSchema,
|
|
55
|
+
to: PieceIdSchema,
|
|
56
|
+
status: z.enum(['new', 'removed']),
|
|
57
|
+
why,
|
|
58
|
+
})).default([]),
|
|
59
|
+
/**
|
|
60
|
+
* How it is built, step by step: each one a commit that leaves the application working,
|
|
61
|
+
* the whole a branch and its pull request. A piece is named in braces,
|
|
62
|
+
* `{boards/card.setDueDate}`, and drawn as itself; a step's pieces are those it names,
|
|
63
|
+
* or those of `focus`. A review finds each step done when its pieces are.
|
|
64
|
+
*/
|
|
65
|
+
steps: z.array(z.strictObject({
|
|
66
|
+
title: z.string().min(1),
|
|
67
|
+
text: z.string().min(1).optional(),
|
|
68
|
+
focus: z.array(PieceIdSchema).optional(),
|
|
69
|
+
})).default([]),
|
|
70
|
+
/** What was checked by hand; `checkPlan` adds what it checks itself. */
|
|
71
|
+
rules: z.array(z.strictObject({ ok: z.boolean(), text: z.string().min(1) })).default([]),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export type Plan = z.output<typeof PlanSchema>
|
|
75
|
+
export type PlanInput = z.input<typeof PlanSchema>
|
package/plan/resolve.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { moduleOf, splitModuleId, type AppMap, type MapRelation, type PieceKind, type RelationKind } from '../map/app-map'
|
|
2
|
+
import type { Plan } from './plan.schema'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* - `new`, `modified`, `removed`: what the plan says it does.
|
|
6
|
+
* - `consumed`: there before and left as it is, but what is new or taken away meets it
|
|
7
|
+
* (an event a new use case listens to, a use case a new one calls).
|
|
8
|
+
* - `unchanged`: the rest of the application.
|
|
9
|
+
*/
|
|
10
|
+
export type Status = 'new' | 'modified' | 'consumed' | 'unchanged' | 'removed'
|
|
11
|
+
|
|
12
|
+
export type ResolvedPiece = {
|
|
13
|
+
id: string
|
|
14
|
+
kind: PieceKind
|
|
15
|
+
name: string
|
|
16
|
+
status: Status
|
|
17
|
+
cron?: string
|
|
18
|
+
why?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ResolvedModule = {
|
|
22
|
+
id: string
|
|
23
|
+
context: string
|
|
24
|
+
name: string
|
|
25
|
+
port: boolean
|
|
26
|
+
status: Status
|
|
27
|
+
why?: string
|
|
28
|
+
pieces: ResolvedPiece[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ResolvedRelation = {
|
|
32
|
+
kind: RelationKind
|
|
33
|
+
from: string
|
|
34
|
+
to: string
|
|
35
|
+
status: 'new' | 'removed' | 'unchanged'
|
|
36
|
+
queue?: string
|
|
37
|
+
conditional?: boolean
|
|
38
|
+
why?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `map`: the application as it is; `plan`: a feature to build; `review`: a branch's change. */
|
|
42
|
+
export type Mode = 'map' | 'plan' | 'review'
|
|
43
|
+
|
|
44
|
+
export type ResolvedStep = { title: string, text?: string, focus: string[] }
|
|
45
|
+
|
|
46
|
+
export type ResolvedPlan = {
|
|
47
|
+
mode: Mode
|
|
48
|
+
language: Plan['language']
|
|
49
|
+
title: string
|
|
50
|
+
subtitle?: string
|
|
51
|
+
contexts: { name: string, uses: string[], status: 'new' | 'removed' | 'unchanged' }[]
|
|
52
|
+
modules: ResolvedModule[]
|
|
53
|
+
relations: ResolvedRelation[]
|
|
54
|
+
steps: ResolvedStep[]
|
|
55
|
+
rules: { ok: boolean, text: string }[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class PlanError extends Error {
|
|
59
|
+
constructor(readonly problems: string[]) {
|
|
60
|
+
super(`The plan does not fit the map:\n- ${problems.join('\n- ')}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The queue newt names a subscription by: `reminder.schedule.on.card.dueDateSet`. */
|
|
65
|
+
export function queueOf(from: string, to: string): string {
|
|
66
|
+
const name = (id: string) => id.slice(id.indexOf('/') + 1)
|
|
67
|
+
return `${name(to)}.on.${name(from)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The pieces a text names: `{boards/card.setDueDate}`. */
|
|
71
|
+
export function piecesNamed(text: string): string[] {
|
|
72
|
+
return [...text.matchAll(/\{([a-z][\w-]*\/[a-zA-Z]\w*\.[a-zA-Z]\w*)\}/g)].map(match => match[1]!)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The plan laid on the map: every module and piece with its status, every relation with
|
|
77
|
+
* its own. Throws a `PlanError` saying each thing the plan says that the map contradicts.
|
|
78
|
+
*/
|
|
79
|
+
export function resolvePlan(map: AppMap, plan: Plan, mode: Mode = 'plan'): ResolvedPlan {
|
|
80
|
+
const problems: string[] = []
|
|
81
|
+
const contexts = new Map(map.contexts.map(context => [context.name, { name: context.name, uses: [...context.uses], status: 'unchanged' as 'new' | 'removed' | 'unchanged' }]))
|
|
82
|
+
const modules = new Map<string, ResolvedModule>()
|
|
83
|
+
const pieces = new Map<string, ResolvedPiece>()
|
|
84
|
+
for (const module of map.modules) {
|
|
85
|
+
const resolved: ResolvedModule = { id: module.id, context: module.context, name: module.name, port: module.port, status: 'unchanged', pieces: [] }
|
|
86
|
+
modules.set(module.id, resolved)
|
|
87
|
+
for (const piece of module.pieces) {
|
|
88
|
+
const each: ResolvedPiece = { id: piece.id, kind: piece.kind, name: piece.name, status: 'unchanged', ...(piece.cron ? { cron: piece.cron } : {}) }
|
|
89
|
+
resolved.pieces.push(each)
|
|
90
|
+
pieces.set(piece.id, each)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const context of plan.contexts) {
|
|
95
|
+
const found = contexts.get(context.name)
|
|
96
|
+
if (context.status === 'new') {
|
|
97
|
+
if (found) {
|
|
98
|
+
problems.push(`The context ${context.name} is new, but the map has it`)
|
|
99
|
+
} else {
|
|
100
|
+
contexts.set(context.name, { name: context.name, uses: [], status: 'new' })
|
|
101
|
+
}
|
|
102
|
+
} else if (!found) {
|
|
103
|
+
problems.push(`The context ${context.name} is removed, but the map has it not`)
|
|
104
|
+
} else {
|
|
105
|
+
found.status = 'removed'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const module of plan.modules) {
|
|
110
|
+
const found = modules.get(module.id)
|
|
111
|
+
const { context, module: name } = splitModuleId(module.id)
|
|
112
|
+
if (module.status === 'new') {
|
|
113
|
+
if (found) {
|
|
114
|
+
problems.push(`The module ${module.id} is new, but the map has it`)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (!contexts.has(context)) {
|
|
118
|
+
problems.push(`The module ${module.id} is of the context ${context}, which neither the map nor the plan has`)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
modules.set(module.id, { id: module.id, context, name, port: module.port ?? context === 'ports', status: 'new', pieces: [], ...(module.why ? { why: module.why } : {}) })
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (!found) {
|
|
125
|
+
problems.push(`The plan speaks of the module ${module.id}, which the map has not`)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (module.status === 'removed') {
|
|
129
|
+
found.status = 'removed'
|
|
130
|
+
}
|
|
131
|
+
if (module.why) {
|
|
132
|
+
found.why = module.why
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const piece of plan.pieces) {
|
|
137
|
+
const found = pieces.get(piece.id)
|
|
138
|
+
if (piece.status === 'new') {
|
|
139
|
+
if (found) {
|
|
140
|
+
problems.push(`${piece.id} is new, but the map has it: say modified`)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
const module = modules.get(moduleOf(piece.id))
|
|
144
|
+
if (!module) {
|
|
145
|
+
problems.push(`${piece.id} is of the module ${moduleOf(piece.id)}, which neither the map nor the plan has`)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const each: ResolvedPiece = { id: piece.id, kind: piece.kind, name: piece.id.slice(piece.id.indexOf('.') + 1), status: 'new', ...(piece.cron ? { cron: piece.cron } : {}), ...(piece.why ? { why: piece.why } : {}) }
|
|
149
|
+
module.pieces.push(each)
|
|
150
|
+
pieces.set(piece.id, each)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (!found) {
|
|
154
|
+
problems.push(`The plan speaks of ${piece.id}, which the map has not`)
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (found.kind !== piece.kind) {
|
|
158
|
+
problems.push(`${piece.id} is ${article(found.kind)} in the map, not ${article(piece.kind)}`)
|
|
159
|
+
}
|
|
160
|
+
if (piece.status) {
|
|
161
|
+
found.status = piece.status
|
|
162
|
+
}
|
|
163
|
+
if (piece.why) {
|
|
164
|
+
found.why = piece.why
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const module of modules.values()) {
|
|
168
|
+
if (module.status === 'removed') {
|
|
169
|
+
for (const piece of module.pieces) {
|
|
170
|
+
piece.status = 'removed'
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (module.status === 'new') {
|
|
174
|
+
for (const piece of module.pieces) {
|
|
175
|
+
if (piece.status !== 'new') {
|
|
176
|
+
problems.push(`${piece.id} is in the new module ${module.id}, so it is new too`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const relations = new Map<string, ResolvedRelation>()
|
|
183
|
+
const keyOf = (relation: { kind: string, from: string, to: string }) => `${relation.kind} ${relation.from} ${relation.to}`
|
|
184
|
+
for (const relation of map.relations) {
|
|
185
|
+
relations.set(keyOf(relation), withStatus(relation, 'unchanged'))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const relation of plan.relations) {
|
|
189
|
+
const from = pieces.get(relation.from), to = pieces.get(relation.to)
|
|
190
|
+
if (!from) {
|
|
191
|
+
problems.push(`A relation from ${relation.from}, which neither the map nor the plan has`)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (!to) {
|
|
195
|
+
problems.push(`A relation to ${relation.to}, which neither the map nor the plan has`)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
const found = relations.get(keyOf(relation))
|
|
199
|
+
if (relation.status === 'removed') {
|
|
200
|
+
if (!found) {
|
|
201
|
+
problems.push(`The plan takes away ${relation.from} ${relation.kind} ${relation.to}, which the map has not`)
|
|
202
|
+
} else {
|
|
203
|
+
found.status = 'removed'
|
|
204
|
+
if (relation.why) {
|
|
205
|
+
found.why = relation.why
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (found) {
|
|
211
|
+
problems.push(`${relation.from} ${relation.kind} ${relation.to} is new, but the map has it`)
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const wrong = kindProblem(relation.kind, from, to)
|
|
215
|
+
if (wrong) {
|
|
216
|
+
problems.push(wrong)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (from.status === 'removed' || to.status === 'removed') {
|
|
220
|
+
problems.push(`${relation.from} ${relation.kind} ${relation.to} is new, but one of its ends is removed`)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
relations.set(keyOf(relation), {
|
|
224
|
+
kind: relation.kind, from: relation.from, to: relation.to, status: 'new',
|
|
225
|
+
...(relation.kind === 'triggers' ? { queue: queueOf(relation.from, relation.to) } : {}),
|
|
226
|
+
...(relation.why ? { why: relation.why } : {}),
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
// What a removed piece was in goes with it.
|
|
230
|
+
for (const relation of relations.values()) {
|
|
231
|
+
if (relation.status === 'unchanged' && (pieces.get(relation.from)?.status === 'removed' || pieces.get(relation.to)?.status === 'removed')) {
|
|
232
|
+
relation.status = 'removed'
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// What changes meets what stays: the use case a new call or emit starts from has new
|
|
237
|
+
// code, so it is modified; whatever else a change meets is consumed.
|
|
238
|
+
for (const relation of relations.values()) {
|
|
239
|
+
if (relation.status === 'unchanged') {
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
const from = pieces.get(relation.from)!, to = pieces.get(relation.to)!
|
|
243
|
+
if (from.status === 'unchanged') {
|
|
244
|
+
from.status = relation.kind === 'triggers' ? 'consumed' : 'modified'
|
|
245
|
+
}
|
|
246
|
+
if (to.status === 'unchanged') {
|
|
247
|
+
to.status = 'consumed'
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const module of modules.values()) {
|
|
252
|
+
if (module.status !== 'unchanged') {
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
const statuses = new Set(module.pieces.map(piece => piece.status))
|
|
256
|
+
// A new subscription is written in the module that listens: its workers change.
|
|
257
|
+
const listensAnew = [...relations.values()].some(relation => relation.kind === 'triggers' && relation.status !== 'unchanged' && moduleOf(relation.to) === module.id)
|
|
258
|
+
if (statuses.has('new') || statuses.has('modified') || statuses.has('removed') || listensAnew) {
|
|
259
|
+
module.status = 'modified'
|
|
260
|
+
} else if (statuses.has('consumed')) {
|
|
261
|
+
module.status = 'consumed'
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const steps = plan.steps.map((step, at) => {
|
|
266
|
+
const named = piecesNamed(`${step.title} ${step.text ?? ''}`)
|
|
267
|
+
for (const id of [...named, ...step.focus ?? []]) {
|
|
268
|
+
if (!pieces.has(id)) {
|
|
269
|
+
problems.push(`Step ${at + 1} names ${id}, which neither the map nor the plan has`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { title: step.title, ...(step.text ? { text: step.text } : {}), focus: step.focus ?? named }
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
if (problems.length) {
|
|
276
|
+
throw new PlanError(problems)
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
mode,
|
|
280
|
+
language: plan.language,
|
|
281
|
+
title: plan.title,
|
|
282
|
+
...(plan.subtitle ? { subtitle: plan.subtitle } : {}),
|
|
283
|
+
contexts: [...contexts.values()],
|
|
284
|
+
modules: [...modules.values()],
|
|
285
|
+
relations: [...relations.values()],
|
|
286
|
+
steps,
|
|
287
|
+
rules: plan.rules,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const article = (kind: PieceKind) => kind === 'event' ? 'an event' : kind === 'schedule' ? 'a schedule' : 'a use case'
|
|
292
|
+
|
|
293
|
+
function withStatus(relation: MapRelation, status: ResolvedRelation['status']): ResolvedRelation {
|
|
294
|
+
return { kind: relation.kind, from: relation.from, to: relation.to, status, ...(relation.queue ? { queue: relation.queue } : {}), ...(relation.conditional ? { conditional: true } : {}) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function kindProblem(kind: RelationKind, from: ResolvedPiece, to: ResolvedPiece): string | null {
|
|
298
|
+
const says = `${from.id} ${kind} ${to.id}`
|
|
299
|
+
if (kind === 'emits') {
|
|
300
|
+
if (from.kind !== 'useCase' || to.kind !== 'event') {
|
|
301
|
+
return `${says}: a use case emits an event`
|
|
302
|
+
}
|
|
303
|
+
if (moduleOf(from.id) !== moduleOf(to.id)) {
|
|
304
|
+
return `${says}: a use case emits only its own module's events (its entity tracks them)`
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (kind === 'triggers' && (from.kind === 'useCase' || to.kind !== 'useCase')) {
|
|
308
|
+
return `${says}: an event or a schedule triggers a use case`
|
|
309
|
+
}
|
|
310
|
+
if (kind === 'calls' && (from.kind !== 'useCase' || to.kind !== 'useCase')) {
|
|
311
|
+
return `${says}: a use case calls a use case`
|
|
312
|
+
}
|
|
313
|
+
return null
|
|
314
|
+
}
|
package/plan/review.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AppMap } from '../map/app-map'
|
|
2
|
+
import { pageData, type PageData } from '../render/page-data'
|
|
3
|
+
import { checkPlan } from './check'
|
|
4
|
+
import { comparePlan, type Comparison } from './compare'
|
|
5
|
+
import { diffMaps } from './diff'
|
|
6
|
+
import type { Plan } from './plan.schema'
|
|
7
|
+
import { piecesNamed, resolvePlan, type ResolvedPlan } from './resolve'
|
|
8
|
+
|
|
9
|
+
export type Review = { resolved: ResolvedPlan, comparison?: Comparison, data: PageData }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A branch's change, `head` against `base`, drawn on `base` with the rules checked; with a
|
|
13
|
+
* plan, held against it too: each thing it says done or not, and each of its steps. The
|
|
14
|
+
* command line and a review's page (when a plan is loaded in it) both make it here.
|
|
15
|
+
*/
|
|
16
|
+
export function reviewOf(base: AppMap, head: AppMap, options: { title: string, language?: Plan['language'], plan?: Plan }): Review {
|
|
17
|
+
const { plan } = options
|
|
18
|
+
const language = plan?.language ?? options.language ?? 'en'
|
|
19
|
+
const found = diffMaps(base, head, { title: plan?.title ?? options.title, language, ...(plan?.subtitle ? { subtitle: plan.subtitle } : {}) })
|
|
20
|
+
const drawn = resolvePlan(base, found, 'review')
|
|
21
|
+
const resolved = { ...drawn, rules: [...checkPlan(drawn), ...plan?.rules ?? []] }
|
|
22
|
+
if (!plan) {
|
|
23
|
+
return { resolved, data: pageData(resolved, { maps: { base, head } }) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const comparison = comparePlan(plan, found, head)
|
|
27
|
+
const drawnPieces = new Set(resolved.modules.flatMap(module => module.pieces.map(piece => piece.id)))
|
|
28
|
+
const steps = plan.steps.map((step, at) => ({
|
|
29
|
+
...(step.text ? { text: step.text } : {}),
|
|
30
|
+
focus: (step.focus ?? piecesNamed(`${step.title} ${step.text ?? ''}`)).filter(id => drawnPieces.has(id)),
|
|
31
|
+
...comparison.steps[at]!,
|
|
32
|
+
}))
|
|
33
|
+
return { resolved, comparison, data: pageData(resolved, { steps, checks: comparison.checks, maps: { base, head } }) }
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// What a review's page runs when a plan is loaded in it: the same review the command line makes.
|
|
2
|
+
import { PlanSchema } from '../plan/plan.schema'
|
|
3
|
+
import { reviewOf } from '../plan/review'
|
|
4
|
+
import type { PageData } from './page-data'
|
|
5
|
+
|
|
6
|
+
/** The page's data for a review held against the plan in `json`, or the problems with it. */
|
|
7
|
+
function reviewWithPlan(data: PageData, json: unknown): { data: PageData } | { problems: string[] } {
|
|
8
|
+
const parsed = PlanSchema.safeParse(json)
|
|
9
|
+
if (!parsed.success) {
|
|
10
|
+
return { problems: parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`) }
|
|
11
|
+
}
|
|
12
|
+
const { base, head } = data.maps!
|
|
13
|
+
return { data: reviewOf(base, head, { title: data.title, plan: parsed.data }).data }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Object.assign(globalThis, { newtPlan: { reviewWithPlan } })
|