@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Angel Romero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @newt-kit/plan
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [newt](https://github.com/aromero86/newt) application drawn from its code: its contexts,
4
+ modules, use cases, events and schedules, and how they meet. A story's plan checked against
5
+ the code and newt's rules before it is written, and a branch held against its plan once it is.
6
+
7
+ ```sh
8
+ bun add -d @newt-kit/plan
9
+ ```
10
+
11
+ newt's command line runs it:
12
+
13
+ ```sh
14
+ bunx newt map # the application, drawn: newt-map.html
15
+ bunx newt map --json # its map, as JSON
16
+ bunx newt map --plan stories/12.plan.json # the plan checked and drawn: stories/12.html
17
+ bunx newt review --base main # what the branch changes, drawn: review.html
18
+ bunx newt review --base main --plan stories/12.plan.json --format markdown
19
+ ```
20
+
21
+ The map is read from the code, never run and never kept: the `<Name>_Module` classes of
22
+ `<context>/modules/<module>/<module>.module.ts`, their `@UseCase` methods, `eventsOf(…)`,
23
+ `schedules.define(…)`, the workers (`module.on(event, 'useCase')`), and, followed through
24
+ the entity, what each use case emits and calls (another module's use case, or a port). What
25
+ falls outside newt's layout is said as a warning.
26
+
27
+ A plan says only what a story changes, in steps: each a commit that leaves the application
28
+ working, the whole story a branch. `newt review --plan` tells each thing done, missing,
29
+ different or not planned, and each step done, half done or not started, by what is in the
30
+ code. It exits with 1 when something is missing or not planned (`--warn-only` does not).
31
+
32
+ The pages are always the same template with their data in it. A plan's page saves its plan
33
+ ("Guardar plan"); a review's page loads one ("Cargar plan") and holds the branch against it.
34
+
35
+ `newt init` and `newt agents` copy its Claude Code skills into `.claude/skills/`: `newt-plan` plans a story
36
+ and `newt-review` reviews a branch.
@@ -0,0 +1,184 @@
1
+ // `newt map` and `newt review`: the map of an application read from its code, a plan laid
2
+ // on it, a branch's change, and their pages. newt's command line runs them from here.
3
+
4
+ import { mkdtemp, rm, stat, symlink } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { basename, dirname, join, relative, resolve } from 'node:path'
7
+ import { parseArgs } from 'node:util'
8
+ import { readMap } from '../map/read-map'
9
+ import { checkPlan } from '../plan/check'
10
+ import { reviewMarkdown } from '../plan/markdown'
11
+ import { PlanSchema, type Plan } from '../plan/plan.schema'
12
+ import { PlanError, resolvePlan } from '../plan/resolve'
13
+ import { reviewOf } from '../plan/review'
14
+ import { pageData } from '../render/page-data'
15
+ import { renderPage } from '../render/render'
16
+
17
+ export const USAGE = `newt map [--plan <plan.json>] [--out <page.html>] [--json] [--tsconfig tsconfig.json] [--within <folder>] [--language en|es]
18
+ The application as its code is, drawn: its contexts, modules, use cases, events and
19
+ schedules, and how they meet. --json prints the map instead. With --plan, the plan laid
20
+ on it, checked against the code and newt's rules, and drawn (beside the plan unless --out).
21
+
22
+ newt review [--base main] [--plan <plan.json>] [--format html|markdown|json] [--out <file>] [--warn-only]
23
+ [--tsconfig tsconfig.json] [--within <folder>] [--title <title>] [--language en|es]
24
+ What the code on disk changes from --base (a branch, a tag, origin/main), drawn. With
25
+ --plan, held against it: each thing done, missing, different or not planned, and each
26
+ step done or not. Exits with 1 when something is missing or not planned, unless --warn-only.
27
+ html is written to --out (review.html); markdown and json are printed, or written to --out.`
28
+
29
+ export type Output = { lines: string[], code: number }
30
+
31
+ const OPTIONS = {
32
+ plan: { type: 'string' }, out: { type: 'string' }, json: { type: 'boolean' }, tsconfig: { type: 'string' }, within: { type: 'string' },
33
+ language: { type: 'string' }, base: { type: 'string' }, format: { type: 'string' }, 'warn-only': { type: 'boolean' }, title: { type: 'string' },
34
+ } as const
35
+
36
+ const languageOf = (value: string | undefined) => value === 'es' ? 'es' as const : value === undefined || value === 'en' ? 'en' as const : null
37
+ const warningsOf = (warnings: string[]) => warnings.map(warning => `warning: ${warning}`)
38
+
39
+ async function readPlan(path: string): Promise<Plan | string[]> {
40
+ const file = Bun.file(path)
41
+ if (!await file.exists()) {
42
+ return [`No file ${path}`]
43
+ }
44
+ const parsed = PlanSchema.safeParse(await file.json())
45
+ if (parsed.success) {
46
+ return parsed.data
47
+ }
48
+ return ['The plan is not one:', ...parsed.error.issues.map(issue => `- ${issue.path.join('.')}: ${issue.message}`)]
49
+ }
50
+
51
+ /** Runs `map` or `review`, in `cwd`: what to print, and the exit code. */
52
+ export async function run(args: string[], cwd: string): Promise<Output> {
53
+ const [command, ...rest] = args
54
+ if (command !== 'map' && command !== 'review') {
55
+ return { lines: [USAGE], code: command === undefined || command === 'help' ? 0 : 1 }
56
+ }
57
+ try {
58
+ const { values, positionals } = parseArgs({ args: rest, options: OPTIONS, allowPositionals: true })
59
+ const language = languageOf(values.language)
60
+ if (positionals.length || language === null) {
61
+ return { lines: [USAGE], code: 1 }
62
+ }
63
+ const at = (path: string) => resolve(cwd, path)
64
+ const tsconfig = at(values.tsconfig ?? 'tsconfig.json')
65
+ const within = values.within === undefined ? undefined : at(values.within)
66
+ return command === 'map'
67
+ ? await map({ cwd, tsconfig, within, plan: values.plan && at(values.plan), out: values.out && at(values.out), json: values.json ?? false, language })
68
+ : await review({ cwd, tsconfig, within, base: values.base ?? 'main', plan: values.plan && at(values.plan), out: values.out && at(values.out), format: values.format ?? 'html', warnOnly: values['warn-only'] ?? false, title: values.title, language: values.language === undefined ? undefined : language })
69
+ } catch (error) {
70
+ return { lines: [error instanceof Error ? error.message : String(error)], code: 1 }
71
+ }
72
+ }
73
+
74
+ /** An application's name: its package.json's, or its folder's. */
75
+ async function nameOf(folder: string): Promise<string> {
76
+ const manifest = Bun.file(join(folder, 'package.json'))
77
+ const named = await manifest.exists() ? (await manifest.json() as { name?: string }).name : undefined
78
+ return named ?? basename(folder)
79
+ }
80
+
81
+ type MapOptions = { cwd: string, tsconfig: string, within: string | undefined, plan: string | undefined, out: string | undefined, json: boolean, language: 'en' | 'es' }
82
+
83
+ async function map(options: MapOptions): Promise<Output> {
84
+ const { map, warnings } = readMap(options.tsconfig, options.within)
85
+ if (options.json) {
86
+ return { lines: [JSON.stringify(map, null, 2), ...warningsOf(warnings)], code: 0 }
87
+ }
88
+ if (!options.plan) {
89
+ const out = options.out ?? resolve(options.cwd, 'newt-map.html')
90
+ const title = await nameOf(dirname(options.tsconfig))
91
+ const resolved = resolvePlan(map, PlanSchema.parse({ version: 1, title, language: options.language }), 'map')
92
+ await Bun.write(out, await renderPage(pageData(resolved)))
93
+ return { lines: [`Written ${out}`, ...warningsOf(warnings)], code: 0 }
94
+ }
95
+ const plan = await readPlan(options.plan)
96
+ if (Array.isArray(plan)) {
97
+ return { lines: plan, code: 1 }
98
+ }
99
+ try {
100
+ const laid = resolvePlan(map, plan)
101
+ const resolved = { ...laid, rules: [...checkPlan(laid), ...laid.rules] }
102
+ const out = options.out ?? options.plan.replace(/(\.plan)?\.json$/, '') + '.html'
103
+ await Bun.write(out, await renderPage(pageData(resolved, { plan })))
104
+ return { lines: [...resolved.rules.map(rule => `${rule.ok ? '✓' : '!'} ${rule.text}`), `Written ${out}`, ...warningsOf(warnings)], code: 0 }
105
+ } catch (error) {
106
+ if (error instanceof PlanError) {
107
+ return { lines: [error.message, ...warningsOf(warnings)], code: 1 }
108
+ }
109
+ throw error
110
+ }
111
+ }
112
+
113
+ type ReviewOptions = {
114
+ cwd: string, tsconfig: string, within: string | undefined, base: string, plan: string | undefined, out: string | undefined
115
+ format: string, warnOnly: boolean, title: string | undefined, language: 'en' | 'es' | undefined
116
+ }
117
+
118
+ async function review(options: ReviewOptions): Promise<Output> {
119
+ if (!['html', 'markdown', 'json'].includes(options.format)) {
120
+ return { lines: [USAGE], code: 1 }
121
+ }
122
+ const plan = options.plan ? await readPlan(options.plan) : undefined
123
+ if (Array.isArray(plan)) {
124
+ return { lines: plan, code: 1 }
125
+ }
126
+
127
+ const head = readMap(options.tsconfig, options.within)
128
+ const base = await atRevision(options.cwd, options.base, (folder, root) => readMap(moved(options.tsconfig, root, folder), options.within && moved(options.within, root, folder)))
129
+ const branch = (await Bun.$`git rev-parse --abbrev-ref HEAD`.cwd(options.cwd).quiet().text()).trim()
130
+ const made = reviewOf(base.map, head.map, { title: options.title ?? `${branch} ← ${options.base}`, ...(options.language ? { language: options.language } : {}), ...(plan ? { plan } : {}) })
131
+ const failing = made.comparison?.failing ?? 0
132
+ const code = failing && !options.warnOnly ? 1 : 0
133
+ const warnings = warningsOf([...new Set([...base.warnings, ...head.warnings])])
134
+
135
+ const text = options.format === 'markdown' ? reviewMarkdown(made)
136
+ : options.format === 'json' ? `${JSON.stringify({ map: head.map, changes: made.resolved, comparison: made.comparison ?? null }, null, 2)}\n`
137
+ : await renderPage(made.data)
138
+ const summary = made.comparison ? [`${failing} to look at`] : []
139
+ if (options.format !== 'html' && !options.out) {
140
+ return { lines: [text.trimEnd(), ...warnings], code }
141
+ }
142
+ const out = options.out ?? resolve(options.cwd, 'review.html')
143
+ await Bun.write(out, text)
144
+ return { lines: [...summary, `Written ${out}`, ...warnings], code }
145
+ }
146
+
147
+ /** A path of the working tree (whose root is `root`), at the same place in `folder`. */
148
+ const moved = (path: string, root: string, folder: string) => join(folder, relative(root, path))
149
+
150
+ /**
151
+ * Runs `read` on the code of `revision`, written out to a folder of its own, with the
152
+ * working tree's node_modules beside it so its imports resolve as they do here.
153
+ */
154
+ async function atRevision<T>(cwd: string, revision: string, read: (folder: string, root: string) => T): Promise<T> {
155
+ // The root as reached from cwd, not --show-toplevel's real path: through a symlink (macOS's
156
+ // /var is /private/var) the paths moved into the folder would not be under it.
157
+ const root = resolve(cwd, (await Bun.$`git rev-parse --show-cdup`.cwd(cwd).quiet().text()).trim())
158
+ const known = await Bun.$`git rev-parse --verify --quiet ${revision}^{commit}`.cwd(root).quiet().nothrow()
159
+ if (known.exitCode !== 0) {
160
+ throw new Error(`No revision ${revision}: in a shallow clone, fetch it (fetch-depth: 0)`)
161
+ }
162
+ const folder = await mkdtemp(join(tmpdir(), 'newt-review-'))
163
+ try {
164
+ await Bun.$`git archive ${revision} | tar -x -C ${folder}`.cwd(root).quiet()
165
+ for (const at of new Set([root, cwd])) {
166
+ const modules = join(at, 'node_modules')
167
+ const there = join(folder, relative(root, at), 'node_modules')
168
+ const exists = await stat(modules).then(() => true, () => false)
169
+ const already = await stat(there).then(() => true, () => false)
170
+ if (exists && !already) {
171
+ await symlink(modules, there).catch(() => undefined)
172
+ }
173
+ }
174
+ return read(folder, root)
175
+ } finally {
176
+ await rm(folder, { recursive: true, force: true })
177
+ }
178
+ }
179
+
180
+ if (import.meta.main) {
181
+ const { lines, code } = await run(Bun.argv.slice(2), process.cwd())
182
+ console[code === 0 ? 'log' : 'error'](lines.join('\n'))
183
+ process.exit(code)
184
+ }
package/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ // @newt-kit/plan: a newt application drawn from its code, a plan laid on it and checked,
2
+ // and a branch's change held against it. newt's command line runs it: `newt map`, `newt review`.
3
+ export type { AppMap, MapContext, MapModule, MapPiece, MapRelation, PieceKind, RelationKind } from './map/app-map'
4
+ export { readMap, contextOfFile, type ReadMap } from './map/read-map'
5
+ export { PlanSchema, type Plan, type PlanInput } from './plan/plan.schema'
6
+ export { resolvePlan, queueOf, piecesNamed, PlanError, type Mode, type ResolvedPlan, type ResolvedModule, type ResolvedPiece, type ResolvedRelation, type ResolvedStep, type Status } from './plan/resolve'
7
+ export { diffMaps } from './plan/diff'
8
+ export { checkPlan, type Rule } from './plan/check'
9
+ export { comparePlan, type Check, type Comparison, type StepCheck, type StepState, type Verdict } from './plan/compare'
10
+ export { reviewOf, type Review } from './plan/review'
11
+ export { reviewMarkdown } from './plan/markdown'
12
+ export { pageData, type PageData, type PageStep } from './render/page-data'
13
+ export { renderPage, TEMPLATE } from './render/render'
14
+ export { run, USAGE } from './cli/commands'
package/map/app-map.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * An application as a plan draws it, read from its code: its contexts, its modules, the
3
+ * pieces of each (use cases, events, schedules) and how they meet.
4
+ *
5
+ * A module's id is `<context>/<module>`; a piece's, `<context>/<module>.<name>`.
6
+ * A port is a module of the context `ports`: `ports/mailer.send`.
7
+ */
8
+ export type AppMap = {
9
+ version: 1
10
+ contexts: MapContext[]
11
+ modules: MapModule[]
12
+ relations: MapRelation[]
13
+ }
14
+
15
+ export type MapContext = {
16
+ name: string
17
+ /** The contexts its modules depend on. */
18
+ uses: string[]
19
+ }
20
+
21
+ export type MapModule = {
22
+ id: string
23
+ context: string
24
+ name: string
25
+ /** A port: something the module calls that is no module of the application (a mailer). */
26
+ port: boolean
27
+ pieces: MapPiece[]
28
+ }
29
+
30
+ export type PieceKind = 'useCase' | 'event' | 'schedule'
31
+
32
+ export type MapPiece = {
33
+ id: string
34
+ kind: PieceKind
35
+ /** Its name in its module: `create`, `created`. */
36
+ name: string
37
+ /** A schedule's cron expression. */
38
+ cron?: string
39
+ /** A short hash of its code (a use case and what it follows, an event's class): tells it changed. */
40
+ hash?: string
41
+ }
42
+
43
+ export type RelationKind = 'emits' | 'triggers' | 'calls'
44
+
45
+ /**
46
+ * - `emits`: a use case to an event its module's entity tracks while it runs.
47
+ * - `triggers`: an event or a schedule to the use case a subscription to it runs.
48
+ * - `calls`: a use case to a use case of another module, or to a port.
49
+ */
50
+ export type MapRelation = {
51
+ kind: RelationKind
52
+ from: string
53
+ to: string
54
+ /** `triggers`: the subscription's queue. */
55
+ queue?: string
56
+ /** `emits`: only under a condition (`if (diff) this.trackEvent(…)`). */
57
+ conditional?: boolean
58
+ }
59
+
60
+ /** The context a module id is of, and its name: `boards/card` → `boards`, `card`. */
61
+ export function splitModuleId(id: string): { context: string, module: string } {
62
+ const at = id.indexOf('/')
63
+ return { context: id.slice(0, at), module: id.slice(at + 1) }
64
+ }
65
+
66
+ /** The module a piece is of: `boards/card.create` → `boards/card`. */
67
+ export function moduleOf(pieceId: string): string {
68
+ return pieceId.slice(0, pieceId.indexOf('.'))
69
+ }