@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.
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }