@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/map/read-map.ts
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { dirname, resolve, sep } from 'node:path'
|
|
2
|
+
import ts from 'typescript'
|
|
3
|
+
import { moduleOf, type AppMap, type MapModule, type MapPiece, type MapRelation } from './app-map'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The map of an application, read from its code with TypeScript's checker, never run.
|
|
7
|
+
*
|
|
8
|
+
* newt's layout makes it readable: a module is the `<Name>_Module` class of
|
|
9
|
+
* `<context>/modules/<module>/<module>.module.ts`, its use cases its `@UseCase` methods, its
|
|
10
|
+
* events those of `eventsOf(…)` and its schedules `schedules.define(…)`. A worker is
|
|
11
|
+
* `module.on(event, 'useCase')`. From each use case every call is followed into the entity
|
|
12
|
+
* and the module's own helpers: `trackEvent(new X(…))` emits, another module's use case or
|
|
13
|
+
* a port (`this.deps.mailer.send`) is a call. What falls outside this is a warning.
|
|
14
|
+
*/
|
|
15
|
+
export type ReadMap = { map: AppMap, warnings: string[] }
|
|
16
|
+
|
|
17
|
+
type ModuleInfo = {
|
|
18
|
+
module: MapModule
|
|
19
|
+
/** Its fields that hold a schedule or an event: `nightly` → its id. */
|
|
20
|
+
fields: Map<string, string>
|
|
21
|
+
/** Its fields that hold `eventsOf(…)`: `events` → `created` → its id. */
|
|
22
|
+
groups: Map<string, Map<string, string>>
|
|
23
|
+
declaration: ts.ClassDeclaration
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const lowerFirst = (text: string) => text.charAt(0).toLowerCase() + text.slice(1)
|
|
27
|
+
const hashOf = (texts: string[]) => Bun.hash(texts.join('\n')).toString(36)
|
|
28
|
+
|
|
29
|
+
/** The context a file is of, by newt's layout: the folder that holds `modules/`. */
|
|
30
|
+
export function contextOfFile(fileName: string): string | null {
|
|
31
|
+
const parts = resolve(fileName).split(sep)
|
|
32
|
+
const at = parts.lastIndexOf('modules')
|
|
33
|
+
const insideAModule = at > 0 && at < parts.length - 2
|
|
34
|
+
return insideAModule ? parts[at - 1]! : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `Card_Module` → `card`. */
|
|
38
|
+
const moduleNameOfClass = (className: string) => lowerFirst(className.replace(/_Module$/, ''))
|
|
39
|
+
|
|
40
|
+
/** The programs of a tsconfig: its own files, and those of the projects it references. */
|
|
41
|
+
function programsOf(tsconfigPath: string, seen = new Set<string>()): ts.Program[] {
|
|
42
|
+
const path = resolve(tsconfigPath)
|
|
43
|
+
if (seen.has(path)) {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
seen.add(path)
|
|
47
|
+
const config = ts.getParsedCommandLineOfConfigFile(path, {}, {
|
|
48
|
+
...ts.sys,
|
|
49
|
+
onUnRecoverableConfigFileDiagnostic: diagnostic => {
|
|
50
|
+
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
if (!config) {
|
|
54
|
+
throw new Error(`No TypeScript project at ${tsconfigPath}`)
|
|
55
|
+
}
|
|
56
|
+
const own = config.fileNames.length ? [ts.createProgram(config.fileNames, config.options)] : []
|
|
57
|
+
const referenced = (config.projectReferences ?? []).flatMap(reference => programsOf(ts.resolveProjectReferencePath(reference), seen))
|
|
58
|
+
return [...own, ...referenced]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reads the map of the application whose code `tsconfigPath` holds, under `within` if given. */
|
|
62
|
+
export function readMap(tsconfigPath: string, within?: string): ReadMap {
|
|
63
|
+
const root = within === undefined ? null : resolve(within)
|
|
64
|
+
const warnings: string[] = []
|
|
65
|
+
const modules = new Map<string, ModuleInfo>()
|
|
66
|
+
const pieces = new Map<string, MapPiece>()
|
|
67
|
+
const relations = new Map<string, MapRelation>()
|
|
68
|
+
const uses = new Map<string, Set<string>>()
|
|
69
|
+
const readFiles = new Set<string>()
|
|
70
|
+
|
|
71
|
+
const relate = (relation: MapRelation) => {
|
|
72
|
+
const key = `${relation.kind} ${relation.from} ${relation.to}`
|
|
73
|
+
const found = relations.get(key)
|
|
74
|
+
// Emitted always somewhere wins over emitted under a condition elsewhere.
|
|
75
|
+
if (found?.conditional && !relation.conditional) {
|
|
76
|
+
delete found.conditional
|
|
77
|
+
}
|
|
78
|
+
if (!found) {
|
|
79
|
+
relations.set(key, relation)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const addPiece = (piece: MapPiece): void => {
|
|
83
|
+
const module = modules.get(moduleOf(piece.id))?.module
|
|
84
|
+
if (!module) {
|
|
85
|
+
warnings.push(`${piece.id} is of no module the code has`)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (pieces.has(piece.id)) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
pieces.set(piece.id, piece)
|
|
92
|
+
module.pieces.push(piece)
|
|
93
|
+
}
|
|
94
|
+
const portModule = (name: string): ModuleInfo => {
|
|
95
|
+
const id = `ports/${name}`
|
|
96
|
+
let found = modules.get(id)
|
|
97
|
+
if (!found) {
|
|
98
|
+
found = { module: { id, context: 'ports', name, port: true, pieces: [] }, fields: new Map(), groups: new Map(), declaration: undefined as never }
|
|
99
|
+
modules.set(id, found)
|
|
100
|
+
}
|
|
101
|
+
return found
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const program of programsOf(tsconfigPath)) {
|
|
105
|
+
const checker = program.getTypeChecker()
|
|
106
|
+
const sources = program.getSourceFiles().filter(source => {
|
|
107
|
+
const file = resolve(source.fileName)
|
|
108
|
+
const isApplicationCode = !file.includes(`${sep}node_modules${sep}`) && !source.isDeclarationFile
|
|
109
|
+
const isWithin = root === null || file.startsWith(root + sep)
|
|
110
|
+
return isApplicationCode && isWithin && contextOfFile(file) !== null && !readFiles.has(file)
|
|
111
|
+
})
|
|
112
|
+
sources.forEach(source => readFiles.add(resolve(source.fileName)))
|
|
113
|
+
|
|
114
|
+
const keyOfClass = (declaration: ts.ClassDeclaration) => `${resolve(declaration.getSourceFile().fileName)}#${declaration.name?.text}`
|
|
115
|
+
const byClass = new Map<string, ModuleInfo>()
|
|
116
|
+
|
|
117
|
+
const classOf = (node: ts.Node | undefined): ts.ClassDeclaration | undefined => {
|
|
118
|
+
for (let at = node; at; at = at.parent) {
|
|
119
|
+
if (ts.isClassDeclaration(at)) {
|
|
120
|
+
return at
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
const aliased = (symbol: ts.Symbol | undefined) => symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
|
|
126
|
+
const isModuleClass = (node: ts.Node | undefined): node is ts.ClassDeclaration & { name: ts.Identifier } =>
|
|
127
|
+
node !== undefined && ts.isClassDeclaration(node) && node.name !== undefined && /_Module$/.test(node.name.text)
|
|
128
|
+
const isUseCase = (node: ts.Node): node is ts.MethodDeclaration =>
|
|
129
|
+
ts.isMethodDeclaration(node) && (ts.getDecorators(node) ?? []).some(each => {
|
|
130
|
+
const expression = ts.isCallExpression(each.expression) ? each.expression.expression : each.expression
|
|
131
|
+
return ts.isIdentifier(expression) && expression.text === 'UseCase'
|
|
132
|
+
})
|
|
133
|
+
/** The module class a value's type is: `deps.sales.order` → Order_Module. */
|
|
134
|
+
const moduleOfType = (expression: ts.Expression): ModuleInfo | undefined => {
|
|
135
|
+
const declaration = checker.getTypeAtLocation(expression).getSymbol()?.declarations?.find(isModuleClass)
|
|
136
|
+
return declaration ? byClass.get(keyOfClass(declaration)) : undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** An event class's id and hash: its `static eventName`, in the context of its file. */
|
|
140
|
+
const eventOfClass = (expression: ts.Expression, near: string): { id: string, hash: string } | null => {
|
|
141
|
+
const declaration = aliased(checker.getSymbolAtLocation(expression))?.declarations?.find(ts.isClassDeclaration)
|
|
142
|
+
const text = expression.getText()
|
|
143
|
+
if (!declaration) {
|
|
144
|
+
warnings.push(`${near}: the event class ${text} is not found`)
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
const nameMember = declaration.members.find(member => ts.isPropertyDeclaration(member) && member.name.getText() === 'eventName')
|
|
148
|
+
const initializer = nameMember && ts.isPropertyDeclaration(nameMember) ? nameMember.initializer : undefined
|
|
149
|
+
const context = contextOfFile(declaration.getSourceFile().fileName)
|
|
150
|
+
if (!initializer || !ts.isStringLiteralLike(initializer) || context === null) {
|
|
151
|
+
warnings.push(`${near}: the event class ${text} has no static eventName = '<module>.<what>' in a module's folder`)
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
return { id: `${context}/${initializer.text}`, hash: hashOf([declaration.getText()]) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The modules, their events and schedules.
|
|
158
|
+
for (const source of sources) {
|
|
159
|
+
const file = resolve(source.fileName)
|
|
160
|
+
if (!file.endsWith('.module.ts')) {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const context = contextOfFile(file)!
|
|
164
|
+
const classes = source.statements.filter(isModuleClass)
|
|
165
|
+
if (!classes.length) {
|
|
166
|
+
warnings.push(`${file}: a .module.ts with no <Name>_Module class`)
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
for (const declaration of classes) {
|
|
170
|
+
const constructor = declaration.members.find(ts.isConstructorDeclaration)
|
|
171
|
+
let name = moduleNameOfClass(declaration.name.text)
|
|
172
|
+
constructor?.body?.forEachChild(function find(node): void {
|
|
173
|
+
const callsSuper = ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.SuperKeyword
|
|
174
|
+
if (callsSuper && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) {
|
|
175
|
+
name = node.arguments[0].text
|
|
176
|
+
}
|
|
177
|
+
node.forEachChild(find)
|
|
178
|
+
})
|
|
179
|
+
const info: ModuleInfo = { module: { id: `${context}/${name}`, context, name, port: false, pieces: [] }, fields: new Map(), groups: new Map(), declaration }
|
|
180
|
+
modules.set(info.module.id, info)
|
|
181
|
+
byClass.set(keyOfClass(declaration), info)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const info of [...byClass.values()]) {
|
|
186
|
+
const { declaration, module } = info
|
|
187
|
+
const assignments: [string, ts.Expression][] = []
|
|
188
|
+
for (const member of declaration.members) {
|
|
189
|
+
if (ts.isPropertyDeclaration(member) && member.initializer) {
|
|
190
|
+
assignments.push([member.name.getText(), member.initializer])
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
declaration.members.find(ts.isConstructorDeclaration)?.body?.forEachChild(function find(node): void {
|
|
194
|
+
const assignsAField = ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
195
|
+
&& ts.isPropertyAccessExpression(node.left) && node.left.expression.kind === ts.SyntaxKind.ThisKeyword
|
|
196
|
+
if (assignsAField) {
|
|
197
|
+
assignments.push([(node.left as ts.PropertyAccessExpression).name.text, node.right])
|
|
198
|
+
}
|
|
199
|
+
node.forEachChild(find)
|
|
200
|
+
})
|
|
201
|
+
for (const [field, value] of assignments) {
|
|
202
|
+
if (!ts.isCallExpression(value) || !ts.isPropertyAccessExpression(value.expression) && !ts.isIdentifier(value.expression)) {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
const callee = value.expression.getText()
|
|
206
|
+
const [first, second] = value.arguments
|
|
207
|
+
if (/(^|\.)eventsOf$/.test(callee) && second && ts.isObjectLiteralExpression(second)) {
|
|
208
|
+
const group = new Map<string, string>()
|
|
209
|
+
for (const property of second.properties) {
|
|
210
|
+
const eventClass = ts.isPropertyAssignment(property) ? property.initializer : ts.isShorthandPropertyAssignment(property) ? property.name : undefined
|
|
211
|
+
const event = eventClass && eventOfClass(eventClass, module.id)
|
|
212
|
+
if (!event) {
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
group.set(property.name!.getText(), event.id)
|
|
216
|
+
if (moduleOf(event.id) !== module.id) {
|
|
217
|
+
warnings.push(`${module.id}: its event ${event.id} is named after another module`)
|
|
218
|
+
}
|
|
219
|
+
addPiece({ id: event.id, kind: 'event', name: event.id.slice(event.id.indexOf('.') + 1), hash: event.hash })
|
|
220
|
+
}
|
|
221
|
+
info.groups.set(field, group)
|
|
222
|
+
}
|
|
223
|
+
if (/\.schedules\.define$/.test(callee) && first && second && ts.isStringLiteralLike(first) && ts.isStringLiteralLike(second)) {
|
|
224
|
+
const id = `${module.context}/${first.text}`
|
|
225
|
+
info.fields.set(field, id)
|
|
226
|
+
if (moduleOf(id) !== module.id) {
|
|
227
|
+
warnings.push(`${module.id}: its schedule ${first.text} is named after another module`)
|
|
228
|
+
}
|
|
229
|
+
addPiece({ id, kind: 'schedule', name: id.slice(id.indexOf('.') + 1), cron: second.text })
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// What each use case emits and calls.
|
|
235
|
+
const isEntity = (start: ts.ClassDeclaration): boolean => {
|
|
236
|
+
for (let at: ts.ClassDeclaration | undefined = start, depth = 0; at && depth < 10; depth++) {
|
|
237
|
+
const heritage: ts.ExpressionWithTypeArguments | undefined = at.heritageClauses?.find(each => each.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]
|
|
238
|
+
if (!heritage) {
|
|
239
|
+
return false
|
|
240
|
+
}
|
|
241
|
+
if (heritage.expression.getText() === 'Entity') {
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
at = aliased(checker.getSymbolAtLocation(heritage.expression))?.declarations?.find(ts.isClassDeclaration)
|
|
245
|
+
}
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
const isConditional = (node: ts.Node, within: ts.Node): boolean => {
|
|
249
|
+
for (let at = node.parent; at && at !== within; at = at.parent) {
|
|
250
|
+
if (ts.isIfStatement(at) || ts.isConditionalExpression(at) || ts.isCaseClause(at) || ts.isCatchClause(at)) {
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
if (ts.isBinaryExpression(at) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(at.operatorToken.kind)) {
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
if (ts.isForStatement(at) || ts.isForOfStatement(at) || ts.isForInStatement(at) || ts.isWhileStatement(at)) {
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
262
|
+
const isApplicationCode = (node: ts.Node) => contextOfFile(node.getSourceFile().fileName) !== null
|
|
263
|
+
const declarationOf = (call: ts.CallExpression): ts.Declaration | undefined =>
|
|
264
|
+
checker.getResolvedSignature(call)?.declaration ?? aliased(checker.getSymbolAtLocation(call.expression))?.declarations?.[0]
|
|
265
|
+
/**
|
|
266
|
+
* A port: a method an interface of the application declares, reached from the module
|
|
267
|
+
* (`this.deps.mailer.send`, `this.options.mailer.send`, `this.search.index`), named by
|
|
268
|
+
* the field it is reached through.
|
|
269
|
+
*/
|
|
270
|
+
const portOf = (callee: ts.Expression, target: ts.Declaration | undefined): [string, string] | null => {
|
|
271
|
+
const declaredByAnInterface = target !== undefined && (ts.isMethodSignature(target) || ts.isPropertySignature(target))
|
|
272
|
+
const file = target?.getSourceFile()
|
|
273
|
+
const ofTheApplication = file !== undefined && !file.isDeclarationFile && !resolve(file.fileName).includes(`${sep}node_modules${sep}`)
|
|
274
|
+
const match = /^this\.(?:(?:options|deps)\.)?((?:[A-Za-z_$][\w$]*\.)*[A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/.exec(callee.getText())
|
|
275
|
+
if (!declaredByAnInterface || !ofTheApplication || !match) {
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
return [match[1]!.split('.').pop()!, match[2]!]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const info of byClass.values()) {
|
|
282
|
+
const { declaration, module } = info
|
|
283
|
+
for (const member of declaration.members) {
|
|
284
|
+
if (!isUseCase(member)) {
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
const useCase = `${module.id}.${member.name.getText()}`
|
|
288
|
+
const texts: string[] = []
|
|
289
|
+
const seen = new Set<ts.Node>()
|
|
290
|
+
const follow = (fn: ts.FunctionLikeDeclaration, underCondition: boolean): void => {
|
|
291
|
+
if (seen.has(fn) || !fn.body) {
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
seen.add(fn)
|
|
295
|
+
texts.push(fn.getText())
|
|
296
|
+
fn.body.forEachChild(function visit(child): void {
|
|
297
|
+
if (ts.isCallExpression(child)) {
|
|
298
|
+
const conditional = underCondition || isConditional(child, fn)
|
|
299
|
+
const callee = child.expression
|
|
300
|
+
const tracked = child.arguments[0]
|
|
301
|
+
const tracksAnEvent = ts.isPropertyAccessExpression(callee) && callee.name.text === 'trackEvent' && tracked !== undefined && ts.isNewExpression(tracked)
|
|
302
|
+
if (tracksAnEvent) {
|
|
303
|
+
const event = eventOfClass((tracked as ts.NewExpression).expression, useCase)
|
|
304
|
+
if (event) {
|
|
305
|
+
addPiece({ id: event.id, kind: 'event', name: event.id.slice(event.id.indexOf('.') + 1), hash: event.hash })
|
|
306
|
+
relate({ kind: 'emits', from: useCase, to: event.id, ...(conditional ? { conditional: true } : {}) })
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
const target = declarationOf(child)
|
|
310
|
+
const owner = classOf(target)
|
|
311
|
+
const hasBody = target !== undefined && ts.isFunctionLike(target) && 'body' in target && target.body !== undefined
|
|
312
|
+
if (hasBody && owner && isModuleClass(owner) && isUseCase(target)) {
|
|
313
|
+
const called = byClass.get(keyOfClass(owner))
|
|
314
|
+
if (called) {
|
|
315
|
+
relate({ kind: 'calls', from: useCase, to: `${called.module.id}.${target.name!.getText()}` })
|
|
316
|
+
}
|
|
317
|
+
} else if (hasBody && (owner === declaration || (owner && isEntity(owner)) || (!owner && isApplicationCode(target)))) {
|
|
318
|
+
follow(target as ts.FunctionLikeDeclaration, conditional)
|
|
319
|
+
} else if (!hasBody) {
|
|
320
|
+
const port = portOf(callee, target)
|
|
321
|
+
if (port) {
|
|
322
|
+
const [name, method] = port
|
|
323
|
+
portModule(name)
|
|
324
|
+
const id = `ports/${name}.${method}`
|
|
325
|
+
addPiece({ id, kind: 'useCase', name: method })
|
|
326
|
+
relate({ kind: 'calls', from: useCase, to: id })
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
child.forEachChild(visit)
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
follow(member, false)
|
|
335
|
+
addPiece({ id: useCase, kind: 'useCase', name: member.name.getText(), hash: hashOf(texts) })
|
|
336
|
+
}
|
|
337
|
+
// Its deps: the contexts it uses.
|
|
338
|
+
const depsType = declaration.members.find(ts.isConstructorDeclaration)?.parameters.find(parameter => parameter.name.getText() === 'deps')
|
|
339
|
+
if (depsType) {
|
|
340
|
+
const walk = (type: ts.Type, depth: number): void => {
|
|
341
|
+
const target = type.getSymbol()?.declarations?.find(isModuleClass)
|
|
342
|
+
const used = target && byClass.get(keyOfClass(target))
|
|
343
|
+
if (used) {
|
|
344
|
+
if (used.module.context !== module.context) {
|
|
345
|
+
(uses.get(module.context) ?? uses.set(module.context, new Set()).get(module.context)!).add(used.module.context)
|
|
346
|
+
}
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (depth > 3) {
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
for (const property of type.getProperties()) {
|
|
353
|
+
walk(checker.getTypeOfSymbolAtLocation(property, depsType), depth + 1)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
walk(checker.getTypeAtLocation(depsType), 0)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The event or schedule an expression holds: `deps.sales.order.events.created`, `orders.nightly`. */
|
|
361
|
+
const emitterOf = (expression: ts.Expression): string | undefined => {
|
|
362
|
+
const name = ts.isPropertyAccessExpression(expression) ? expression.name : expression
|
|
363
|
+
for (const declaration of checker.getSymbolAtLocation(name)?.declarations ?? []) {
|
|
364
|
+
const inEventsOf = ts.isPropertyAssignment(declaration) || ts.isShorthandPropertyAssignment(declaration)
|
|
365
|
+
const owner = classOf(declaration)
|
|
366
|
+
if (inEventsOf) {
|
|
367
|
+
const eventClass = ts.isPropertyAssignment(declaration) ? declaration.initializer : declaration.name
|
|
368
|
+
const event = eventOfClass(eventClass, 'a worker')
|
|
369
|
+
if (event) {
|
|
370
|
+
return event.id
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (ts.isPropertyDeclaration(declaration) && isModuleClass(owner)) {
|
|
374
|
+
const found = byClass.get(keyOfClass(owner))?.fields.get(declaration.name.getText())
|
|
375
|
+
if (found) {
|
|
376
|
+
return found
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!ts.isPropertyAccessExpression(expression)) {
|
|
381
|
+
return undefined
|
|
382
|
+
}
|
|
383
|
+
const holder = expression.expression
|
|
384
|
+
const direct = moduleOfType(holder)?.fields.get(expression.name.text)
|
|
385
|
+
if (direct) {
|
|
386
|
+
return direct
|
|
387
|
+
}
|
|
388
|
+
if (ts.isPropertyAccessExpression(holder)) {
|
|
389
|
+
return moduleOfType(holder.expression)?.groups.get(holder.name.text)?.get(expression.name.text)
|
|
390
|
+
}
|
|
391
|
+
return undefined
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// The workers: `module.on(event, 'useCase')`.
|
|
395
|
+
for (const source of sources) {
|
|
396
|
+
source.forEachChild(function visit(node): void {
|
|
397
|
+
const isOn = ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'on' && node.arguments.length >= 2
|
|
398
|
+
const listener = isOn ? moduleOfType((node.expression as ts.PropertyAccessExpression).expression) : undefined
|
|
399
|
+
if (isOn && listener) {
|
|
400
|
+
const [emitter, useCase] = node.arguments as unknown as [ts.Expression, ts.Expression]
|
|
401
|
+
const event = emitterOf(emitter)
|
|
402
|
+
const where = `${resolve(source.fileName)}: ${node.getText().split('\n')[0]}`
|
|
403
|
+
if (!ts.isStringLiteralLike(useCase)) {
|
|
404
|
+
warnings.push(`${where}: the use case is not named by a string`)
|
|
405
|
+
} else if (!event) {
|
|
406
|
+
warnings.push(`${where}: the event it listens to is not found`)
|
|
407
|
+
} else {
|
|
408
|
+
const eventName = event.slice(event.indexOf('/') + 1)
|
|
409
|
+
relate({ kind: 'triggers', from: event, to: `${listener.module.id}.${useCase.text}`, queue: `${listener.module.name}.${useCase.text}.on.${eventName}` })
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
node.forEachChild(visit)
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
for (const relation of relations.values()) {
|
|
418
|
+
for (const end of [relation.from, relation.to]) {
|
|
419
|
+
if (!pieces.has(end)) {
|
|
420
|
+
warnings.push(`${relation.from} ${relation.kind} ${end}, which the code has not`)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const relationsOfKnown = [...relations.values()].filter(relation => pieces.has(relation.from) && pieces.has(relation.to))
|
|
425
|
+
// A listener uses the context it listens to.
|
|
426
|
+
for (const relation of relationsOfKnown) {
|
|
427
|
+
const [from, to] = relation.kind === 'triggers' ? [relation.to, relation.from] : [relation.from, relation.to]
|
|
428
|
+
const a = modules.get(moduleOf(from))!.module.context, b = modules.get(moduleOf(to))!.module.context
|
|
429
|
+
if (a !== b && b !== 'ports') {
|
|
430
|
+
(uses.get(a) ?? uses.set(a, new Set()).get(a)!).add(b)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const sortedModules = [...modules.values()].map(info => info.module).sort((a, b) => a.id.localeCompare(b.id))
|
|
435
|
+
const order = { useCase: 0, event: 1, schedule: 2 }
|
|
436
|
+
for (const module of sortedModules) {
|
|
437
|
+
module.pieces.sort((a, b) => order[a.kind] - order[b.kind] || a.name.localeCompare(b.name))
|
|
438
|
+
}
|
|
439
|
+
// The ports last.
|
|
440
|
+
const contextNames = [...new Set(sortedModules.map(module => module.context))].sort((a, b) => Number(a === 'ports') - Number(b === 'ports') || a.localeCompare(b))
|
|
441
|
+
return {
|
|
442
|
+
map: {
|
|
443
|
+
version: 1,
|
|
444
|
+
contexts: contextNames.map(name => ({ name, uses: [...uses.get(name) ?? []].sort() })),
|
|
445
|
+
modules: sortedModules,
|
|
446
|
+
relations: relationsOfKnown.sort((a, b) => `${a.kind} ${a.from} ${a.to}`.localeCompare(`${b.kind} ${b.from} ${b.to}`)),
|
|
447
|
+
},
|
|
448
|
+
warnings: [...new Set(warnings)],
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The folder a tsconfig is in. */
|
|
453
|
+
export const projectFolder = (tsconfigPath: string) => dirname(resolve(tsconfigPath))
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newt-kit/plan",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "A newt application drawn from its code: `newt map` and `newt review`, a feature's plan checked against the code and newt's rules, and a branch held against its plan.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Angel Romero",
|
|
7
|
+
"homepage": "https://aromero86.github.io/newt/manual/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/aromero86/newt.git",
|
|
11
|
+
"directory": "packages/newt-plan"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/aromero86/newt/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"bun",
|
|
16
|
+
"newt",
|
|
17
|
+
"architecture",
|
|
18
|
+
"planning",
|
|
19
|
+
"code-review",
|
|
20
|
+
"claude"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"**/*.ts",
|
|
24
|
+
"render/template.html",
|
|
25
|
+
"skills",
|
|
26
|
+
"!**/*.test.ts",
|
|
27
|
+
"!**/fixtures/**",
|
|
28
|
+
"!tsconfig.json"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./index.ts",
|
|
34
|
+
"./cli": "./cli/commands.ts"
|
|
35
|
+
},
|
|
36
|
+
"newt": {
|
|
37
|
+
"skills": [
|
|
38
|
+
"skills/newt-plan",
|
|
39
|
+
"skills/newt-review"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"bun": ">=1.4.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
47
|
+
"zod": "^4.3.6"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@newt-kit/newt": "0.10.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/plan/check.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { moduleOf, splitModuleId } from '../map/app-map'
|
|
2
|
+
import type { ResolvedPlan, ResolvedRelation } from './resolve'
|
|
3
|
+
|
|
4
|
+
export type Rule = { ok: boolean, text: string }
|
|
5
|
+
|
|
6
|
+
type Words = Record<'cycle' | 'noCycle' | 'uses' | 'usesOk' | 'writes' | 'writesOk' | 'irreversible' | 'irreversibleOk' | 'unheard', (...args: string[]) => string>
|
|
7
|
+
|
|
8
|
+
const WORDS: Record<ResolvedPlan['language'], Words> = {
|
|
9
|
+
en: {
|
|
10
|
+
cycle: path => `Modules depend on each other in a cycle: ${path}. Break it with an event.`,
|
|
11
|
+
noCycle: () => 'No cycle between modules: what it adds depends on what was there, never back.',
|
|
12
|
+
uses: (from, to) => `The context ${from} does not use ${to} yet: add it to contexts.uses in the lint, or reach it through an event.`,
|
|
13
|
+
usesOk: () => 'Every context it reaches across is one its context already uses.',
|
|
14
|
+
writes: (from, to) => `${from} writes into ${to} outside a worker: react to an event instead.`,
|
|
15
|
+
writesOk: () => 'It writes into another module only from a worker reacting to an event.',
|
|
16
|
+
irreversible: (from, to) => `${from} calls ${to}, which cannot be undone, outside a worker: leave it from a worker.`,
|
|
17
|
+
irreversibleOk: () => 'What cannot be undone (a port) is called only from a worker.',
|
|
18
|
+
unheard: event => `Nothing listens to ${event} yet.`,
|
|
19
|
+
},
|
|
20
|
+
es: {
|
|
21
|
+
cycle: path => `Hay módulos que dependen unos de otros en ciclo: ${path}. Rómpelo con un evento.`,
|
|
22
|
+
noCycle: () => 'Sin ciclos entre módulos: lo nuevo depende de lo que había, nunca al revés.',
|
|
23
|
+
uses: (from, to) => `El contexto ${from} aún no usa ${to}: añádelo a contexts.uses del lint, o llega a él con un evento.`,
|
|
24
|
+
usesOk: () => 'Cada contexto al que llega es uno que su contexto ya usa.',
|
|
25
|
+
writes: (from, to) => `${from} escribe en ${to} fuera de un worker: reacciona a un evento.`,
|
|
26
|
+
writesOk: () => 'Solo escribe en otro módulo desde un worker que reacciona a un evento.',
|
|
27
|
+
irreversible: (from, to) => `${from} llama a ${to}, que no se puede deshacer, fuera de un worker: hazlo desde un worker.`,
|
|
28
|
+
irreversibleOk: () => 'Lo que no se puede deshacer (un puerto) solo se llama desde un worker.',
|
|
29
|
+
unheard: event => `Nadie escucha ${event} todavía.`,
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What newt's rules say of what a plan adds, checked on its map: no cycle between modules
|
|
35
|
+
* (rule 9), a context reaches only those it uses, a use case writes into another module
|
|
36
|
+
* only from a worker (rule 6) and calls what cannot be undone only from one (rule 7). And,
|
|
37
|
+
* to know, the new events nothing listens to.
|
|
38
|
+
*/
|
|
39
|
+
export function checkPlan(plan: ResolvedPlan): Rule[] {
|
|
40
|
+
const words = WORDS[plan.language]
|
|
41
|
+
const live = plan.relations.filter(relation => relation.status !== 'removed')
|
|
42
|
+
const added = plan.relations.filter(relation => relation.status === 'new')
|
|
43
|
+
const pieces = new Map(plan.modules.flatMap(module => module.pieces.map(piece => [piece.id, piece] as const)))
|
|
44
|
+
const ports = new Set(plan.modules.filter(module => module.port).map(module => module.id))
|
|
45
|
+
const rules: Rule[] = []
|
|
46
|
+
|
|
47
|
+
// Rule 9: the modules' graph, by calls (a listener depends on the module it listens to).
|
|
48
|
+
const edges = new Map<string, Set<string>>()
|
|
49
|
+
const depend = (from: string, to: string) => {
|
|
50
|
+
if (from !== to && !ports.has(to)) {
|
|
51
|
+
(edges.get(from) ?? edges.set(from, new Set()).get(from)!).add(to)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const relation of live) {
|
|
55
|
+
if (relation.kind === 'calls') {
|
|
56
|
+
depend(moduleOf(relation.from), moduleOf(relation.to))
|
|
57
|
+
}
|
|
58
|
+
if (relation.kind === 'triggers') {
|
|
59
|
+
depend(moduleOf(relation.to), moduleOf(relation.from))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const cycles = new Set<string>()
|
|
63
|
+
for (const relation of added) {
|
|
64
|
+
if (relation.kind === 'emits') {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const [from, to] = relation.kind === 'calls' ? [moduleOf(relation.from), moduleOf(relation.to)] : [moduleOf(relation.to), moduleOf(relation.from)]
|
|
68
|
+
const path = pathBetween(edges, to, from)
|
|
69
|
+
if (path && from !== to) {
|
|
70
|
+
cycles.add([from, ...path].join(' → '))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (cycles.size) {
|
|
74
|
+
for (const path of cycles) {
|
|
75
|
+
rules.push({ ok: false, text: words.cycle(path) })
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
rules.push({ ok: true, text: words.noCycle() })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A context reaches only the contexts it uses.
|
|
82
|
+
const uses = new Map(plan.contexts.map(context => [context.name, new Set(context.uses)]))
|
|
83
|
+
const reaches = new Set<string>()
|
|
84
|
+
for (const relation of added) {
|
|
85
|
+
if (relation.kind === 'emits') {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
const [from, to] = relation.kind === 'calls' ? [relation.from, relation.to] : [relation.to, relation.from]
|
|
89
|
+
const a = splitModuleId(moduleOf(from)).context, b = splitModuleId(moduleOf(to)).context
|
|
90
|
+
if (a !== b && b !== 'ports' && !uses.get(a)?.has(b)) {
|
|
91
|
+
reaches.add(`${a} ${b}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const pair of reaches) {
|
|
95
|
+
rules.push({ ok: false, text: words.uses(...pair.split(' ')) })
|
|
96
|
+
}
|
|
97
|
+
if (!reaches.size) {
|
|
98
|
+
rules.push({ ok: true, text: words.usesOk() })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Rules 6 and 7: a worker is a use case some event or schedule triggers.
|
|
102
|
+
const isWorker = (id: string) => live.some(relation => relation.kind === 'triggers' && relation.to === id)
|
|
103
|
+
const writes = (id: string) => live.some(relation => relation.kind === 'emits' && relation.from === id)
|
|
104
|
+
const outside: ResolvedRelation[] = [], irreversible: ResolvedRelation[] = []
|
|
105
|
+
for (const relation of added) {
|
|
106
|
+
if (relation.kind !== 'calls' || isWorker(relation.from)) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (ports.has(moduleOf(relation.to))) {
|
|
110
|
+
irreversible.push(relation)
|
|
111
|
+
} else if (moduleOf(relation.from) !== moduleOf(relation.to) && writes(relation.to)) {
|
|
112
|
+
outside.push(relation)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const relation of outside) {
|
|
116
|
+
rules.push({ ok: false, text: words.writes(relation.from, relation.to) })
|
|
117
|
+
}
|
|
118
|
+
if (!outside.length) {
|
|
119
|
+
rules.push({ ok: true, text: words.writesOk() })
|
|
120
|
+
}
|
|
121
|
+
for (const relation of irreversible) {
|
|
122
|
+
rules.push({ ok: false, text: words.irreversible(relation.from, relation.to) })
|
|
123
|
+
}
|
|
124
|
+
if (!irreversible.length && added.some(relation => relation.kind === 'calls' && ports.has(moduleOf(relation.to)))) {
|
|
125
|
+
rules.push({ ok: true, text: words.irreversibleOk() })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const [id, piece] of pieces) {
|
|
129
|
+
if (piece.kind === 'event' && piece.status === 'new' && !live.some(relation => relation.kind === 'triggers' && relation.from === id)) {
|
|
130
|
+
rules.push({ ok: false, text: words.unheard(id) })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return rules
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A path of modules from `from` to `to`, if there is one. */
|
|
137
|
+
function pathBetween(edges: Map<string, Set<string>>, from: string, to: string): string[] | null {
|
|
138
|
+
const queue: string[][] = [[from]]
|
|
139
|
+
const seen = new Set([from])
|
|
140
|
+
while (queue.length) {
|
|
141
|
+
const path = queue.shift()!
|
|
142
|
+
const last = path[path.length - 1]!
|
|
143
|
+
if (last === to) {
|
|
144
|
+
return path
|
|
145
|
+
}
|
|
146
|
+
for (const next of edges.get(last) ?? []) {
|
|
147
|
+
if (!seen.has(next)) {
|
|
148
|
+
seen.add(next)
|
|
149
|
+
queue.push([...path, next])
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|