@pikku/skills 0.12.26 → 0.12.27
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,367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Emit the Pikku implementation inventory for a .knowledge/ blueprint.
|
|
3
|
+
//
|
|
4
|
+
// node inventory.mjs <path-to-.knowledge> [--json] [--domain <Name>]
|
|
5
|
+
//
|
|
6
|
+
// Answers "what will actually be built?" BEFORE any code exists: every pikkuFunc,
|
|
7
|
+
// permission, scheduler, queue worker, workflow, HTTP wiring, event channel,
|
|
8
|
+
// table and scenario — plus, crucially, what is BLOCKED by an unresolved decision
|
|
9
|
+
// and what is deliberately NOT built.
|
|
10
|
+
//
|
|
11
|
+
// This is a projection of the blueprint, not a plan you write by hand. It is
|
|
12
|
+
// derived, so it stays honest: if it says 187 functions, the blueprint says 187.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const dir = process.argv[2]
|
|
18
|
+
const asJson = process.argv.includes('--json')
|
|
19
|
+
const domainArg = process.argv.includes('--domain')
|
|
20
|
+
? process.argv[process.argv.indexOf('--domain') + 1]
|
|
21
|
+
: null
|
|
22
|
+
|
|
23
|
+
if (!dir) {
|
|
24
|
+
console.error('usage: inventory.mjs <path-to-.knowledge> [--json] [--domain <Name>]')
|
|
25
|
+
process.exit(2)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The four files pikku-software-archaeology's schema marks `x-optional` — an
|
|
29
|
+
// API-only app really has no frontend. Every other file is REQUIRED, and a
|
|
30
|
+
// missing or unparseable one is fatal rather than an empty list: this script's
|
|
31
|
+
// whole claim is that its counts come from the blueprint, so silently reporting
|
|
32
|
+
// "0 commands" for a directory that isn't a blueprint tells the exact lie the
|
|
33
|
+
// inventory exists to prevent.
|
|
34
|
+
const OPTIONAL = new Set([
|
|
35
|
+
'interfaces.json',
|
|
36
|
+
'frontend.json',
|
|
37
|
+
'frontend-routes.json',
|
|
38
|
+
'frontend-components.json',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
const fatal = (msg) => {
|
|
42
|
+
console.error(`inventory: ${msg}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!existsSync(dir)) fatal(`no such directory: ${dir}`)
|
|
47
|
+
|
|
48
|
+
const read = (f) => {
|
|
49
|
+
const p = join(dir, f)
|
|
50
|
+
if (!existsSync(p)) {
|
|
51
|
+
if (OPTIONAL.has(f)) return null
|
|
52
|
+
fatal(
|
|
53
|
+
`${dir} is missing ${f}. Run the archaeology validator first — ` +
|
|
54
|
+
`an incomplete blueprint gives a count that reads as complete.`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(p, 'utf-8'))
|
|
59
|
+
} catch (e) {
|
|
60
|
+
fatal(`${f} is not valid JSON: ${e.message}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A required file that parsed but carries the wrong shape is the same lie. */
|
|
65
|
+
const list = (f, key) => {
|
|
66
|
+
const doc = read(f)
|
|
67
|
+
if (doc === null) return []
|
|
68
|
+
const rows = doc[key]
|
|
69
|
+
if (rows === undefined) fatal(`${f} has no "${key}" array — blueprint shape changed?`)
|
|
70
|
+
if (!Array.isArray(rows)) fatal(`${f}: "${key}" is ${typeof rows}, expected an array`)
|
|
71
|
+
return rows
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const domains = list('domains.json', 'domains')
|
|
75
|
+
const entities = list('entities.json', 'entities')
|
|
76
|
+
const commands = list('commands.json', 'commands')
|
|
77
|
+
const queries = list('queries.json', 'queries')
|
|
78
|
+
const events = list('events.json', 'events')
|
|
79
|
+
const policies = list('policies.json', 'policies')
|
|
80
|
+
const workflows = list('workflows.json', 'workflows')
|
|
81
|
+
const apiDoc = read('api.json')
|
|
82
|
+
const api = apiDoc.surfaces ?? apiDoc.api ?? fatal('api.json has neither "surfaces" nor "api"')
|
|
83
|
+
const integrations = list('integrations.json', 'integrations')
|
|
84
|
+
const migration = read('migration.json')
|
|
85
|
+
const interfaces = read('interfaces.json')?.interfaces ?? []
|
|
86
|
+
const feComponents = read('frontend-components.json')?.components ?? []
|
|
87
|
+
const feRoutes = read('frontend-routes.json')?.routes ?? []
|
|
88
|
+
|
|
89
|
+
// ---- the decisions gate -----------------------------------------------------
|
|
90
|
+
// A concept named in an unresolved decision's blockedConcepts cannot be built.
|
|
91
|
+
//
|
|
92
|
+
// Answers are recorded OUTSIDE the blueprint — in the rebuild's own knowledge
|
|
93
|
+
// base — because the blueprint is a record of the LEGACY app and must not be
|
|
94
|
+
// rewritten as the rebuild proceeds. So pass the ones already answered:
|
|
95
|
+
//
|
|
96
|
+
// --resolved 1,2,3 (1-based index into migration.json.decisionsNeeded)
|
|
97
|
+
//
|
|
98
|
+
// Anything not listed is still blocking.
|
|
99
|
+
const allDecisions = migration.decisionsNeeded ?? []
|
|
100
|
+
const resolvedArg = process.argv.includes('--resolved')
|
|
101
|
+
? process.argv[process.argv.indexOf('--resolved') + 1]
|
|
102
|
+
: ''
|
|
103
|
+
const resolvedIdx = new Set(
|
|
104
|
+
resolvedArg.split(',').map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
105
|
+
)
|
|
106
|
+
const decisions = allDecisions.filter((_, i) => !resolvedIdx.has(i + 1))
|
|
107
|
+
const resolved = allDecisions.filter((_, i) => resolvedIdx.has(i + 1))
|
|
108
|
+
const blocked = new Map() // concept -> question
|
|
109
|
+
for (const d of decisions) {
|
|
110
|
+
for (const c of d.blockedConcepts ?? []) {
|
|
111
|
+
if (!blocked.has(c)) blocked.set(c, d.question)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const dropped = migration.dropped ?? []
|
|
115
|
+
const droppedNames = new Set(
|
|
116
|
+
dropped.map((d) => (d.path ?? d.file ?? '').split('/').pop())
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const isBlocked = (name, domain) => blocked.has(name) || blocked.has(domain)
|
|
120
|
+
|
|
121
|
+
// ---- classification ---------------------------------------------------------
|
|
122
|
+
// Cron-ish triggers. The blueprint records schedules in prose ("daily at 05:00",
|
|
123
|
+
// "every minute"), because that is how the legacy code expressed them.
|
|
124
|
+
const CRON = /(cron|daily|nightly|hourly|every minute|every \d|schedule|at \d{2}:\d{2})/i
|
|
125
|
+
const QUEUE = /(queue|consumer|perform_later|sidekiq|worker|async)/i
|
|
126
|
+
const WEBHOOK = /(webhook|POST \/webhooks)/i
|
|
127
|
+
// A trigger that fires off a row write is the legacy shape of an EVENT, not of a
|
|
128
|
+
// workflow — a handler doing five unrelated things because there was no bus.
|
|
129
|
+
// These become an event + its consumers, which is the structural upgrade.
|
|
130
|
+
const EVENT = /(after_commit|after_save|on create\/update\/destroy|observer|callback|mirror)/i
|
|
131
|
+
|
|
132
|
+
const classifyWorkflow = (w) => {
|
|
133
|
+
const t = `${w.name} ${w.trigger ?? ''}`
|
|
134
|
+
if (w.kind === 'system') {
|
|
135
|
+
if (WEBHOOK.test(t)) return 'wireHTTP + command (webhook ingress)'
|
|
136
|
+
if (CRON.test(t)) return 'wireScheduler'
|
|
137
|
+
if (EVENT.test(t)) return 'event consumer (realtime/queue)'
|
|
138
|
+
if (QUEUE.test(t)) return 'wireQueueWorker'
|
|
139
|
+
return 'pikkuWorkflowFunc'
|
|
140
|
+
}
|
|
141
|
+
// User and admin journeys are NOT pikku workflows — they are sequences of
|
|
142
|
+
// commands a person drives through the UI. They become scenarios.
|
|
143
|
+
return 'scenario (user journey)'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// HTTP is warranted ONLY where the caller is a system we do not control and
|
|
147
|
+
// cannot ask to speak RPC — i.e. it POSTs to a URL we publish, on its schedule.
|
|
148
|
+
// In practice that means inbound webhooks.
|
|
149
|
+
//
|
|
150
|
+
// Everything else is RPC, including surfaces that look like they need HTTP:
|
|
151
|
+
// - `auth: none` means anonymous, not external. Our own sign-up page calls it.
|
|
152
|
+
// - a tokened/capability URL (a check-in link, a public share link) is a
|
|
153
|
+
// first-party page reading a token; the page can call RPC like any other.
|
|
154
|
+
// The caller being ours is what decides this, not the URL's shape or its auth.
|
|
155
|
+
const needsHttp = (s) => /webhook/i.test(s.path ?? '')
|
|
156
|
+
|
|
157
|
+
const byDomain = (arr) => {
|
|
158
|
+
const m = new Map()
|
|
159
|
+
for (const x of arr) {
|
|
160
|
+
const d = x.domain ?? 'unassigned'
|
|
161
|
+
if (!m.has(d)) m.set(d, [])
|
|
162
|
+
m.get(d).push(x)
|
|
163
|
+
}
|
|
164
|
+
return m
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const cmdByDomain = byDomain(commands)
|
|
168
|
+
const qryByDomain = byDomain(queries)
|
|
169
|
+
const polByDomain = byDomain(policies)
|
|
170
|
+
const evtByDomain = byDomain(events)
|
|
171
|
+
const entByDomain = byDomain(entities)
|
|
172
|
+
|
|
173
|
+
const schedulers = workflows.filter((w) => classifyWorkflow(w) === 'wireScheduler')
|
|
174
|
+
const queueWorkers = workflows.filter((w) => classifyWorkflow(w) === 'wireQueueWorker')
|
|
175
|
+
const ingress = workflows.filter((w) => classifyWorkflow(w).startsWith('wireHTTP'))
|
|
176
|
+
const eventConsumers = workflows.filter((w) => classifyWorkflow(w) === 'event consumer (realtime/queue)')
|
|
177
|
+
const pikkuWorkflows = workflows.filter((w) => classifyWorkflow(w) === 'pikkuWorkflowFunc')
|
|
178
|
+
const journeys = workflows.filter((w) => classifyWorkflow(w) === 'scenario (user journey)')
|
|
179
|
+
const httpSurfaces = api.filter(needsHttp)
|
|
180
|
+
const scenarioCount = workflows.reduce((n, w) => n + (w.scenarios ?? []).length, 0)
|
|
181
|
+
|
|
182
|
+
const blockedCommands = commands.filter((c) => isBlocked(c.name, c.domain))
|
|
183
|
+
const blockedQueries = queries.filter((q) => isBlocked(q.name, q.domain))
|
|
184
|
+
const customLogic = feComponents.filter((c) => c.rebuild === 'custom-logic')
|
|
185
|
+
const cheapComponents = feComponents.filter((c) => c.rebuild !== 'custom-logic')
|
|
186
|
+
|
|
187
|
+
// ---- addons -----------------------------------------------------------------
|
|
188
|
+
// An addon is warranted where a capability is self-contained, reused across
|
|
189
|
+
// domains, and has a clear service seam — which is what a `hard`/`critical`
|
|
190
|
+
// integration is. Advisory: the call is the operator's.
|
|
191
|
+
const addonCandidates = integrations.filter(
|
|
192
|
+
(i) => i.importance === 'critical' || i.replacementDifficulty === 'hard'
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if (asJson) {
|
|
196
|
+
console.log(
|
|
197
|
+
JSON.stringify(
|
|
198
|
+
{
|
|
199
|
+
totals: {
|
|
200
|
+
pikkuFunc: commands.length,
|
|
201
|
+
pikkuSessionlessFuncReadonly: queries.length,
|
|
202
|
+
pikkuPermission: policies.length,
|
|
203
|
+
wireScheduler: schedulers.length,
|
|
204
|
+
wireQueueWorker: queueWorkers.length,
|
|
205
|
+
webhookIngress: ingress.length,
|
|
206
|
+
pikkuWorkflowFunc: pikkuWorkflows.length,
|
|
207
|
+
wireHTTP: httpSurfaces.length,
|
|
208
|
+
eventChannels: events.length,
|
|
209
|
+
tables: entities.length,
|
|
210
|
+
scenarios: scenarioCount,
|
|
211
|
+
blockedCommands: blockedCommands.length,
|
|
212
|
+
blockedQueries: blockedQueries.length,
|
|
213
|
+
droppedArtifacts: dropped.length,
|
|
214
|
+
customLogicComponents: customLogic.length,
|
|
215
|
+
},
|
|
216
|
+
blocked: [...blocked.entries()].map(([concept, question]) => ({ concept, question })),
|
|
217
|
+
},
|
|
218
|
+
null,
|
|
219
|
+
2
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
process.exit(0)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- report -----------------------------------------------------------------
|
|
226
|
+
const out = []
|
|
227
|
+
const p = (s = '') => out.push(s)
|
|
228
|
+
|
|
229
|
+
p('# Pikku implementation inventory')
|
|
230
|
+
p('')
|
|
231
|
+
p(`Derived from \`${dir}\`. Every number below is a projection of the blueprint —`)
|
|
232
|
+
p('nothing here is estimated or invented.')
|
|
233
|
+
p('')
|
|
234
|
+
|
|
235
|
+
p('## Totals')
|
|
236
|
+
p('')
|
|
237
|
+
p('| Pikku artifact | Count | From |')
|
|
238
|
+
p('|---|---:|---|')
|
|
239
|
+
p(`| \`pikkuFunc\` (state-changing) | ${commands.length} | \`commands.json\` |`)
|
|
240
|
+
p(`| \`pikkuSessionlessFunc\` + \`readonly: true\` | ${queries.length} | \`queries.json\` |`)
|
|
241
|
+
p(`| \`pikkuPermission\` | ${policies.length} | \`policies.json\` |`)
|
|
242
|
+
p(`| \`wireScheduler\` | ${schedulers.length} | \`workflows.json\` (system + cron) |`)
|
|
243
|
+
p(`| \`wireQueueWorker\` | ${queueWorkers.length} | \`workflows.json\` (system + queue) |`)
|
|
244
|
+
p(`| webhook ingress (\`wireHTTP\` + command) | ${ingress.length} | \`workflows.json\` (system + webhook) |`)
|
|
245
|
+
p(`| \`pikkuWorkflowFunc\` | ${pikkuWorkflows.length} | \`workflows.json\` (system, multi-step) |`)
|
|
246
|
+
p(`| \`wireHTTP\` (fixed external URLs only) | ${httpSurfaces.length} of ${api.length} surfaces | \`api.json\` |`)
|
|
247
|
+
p(`| event channels (realtime/queue) | ${events.length} | \`events.json\` |`)
|
|
248
|
+
p(`| tables | ${entities.length} | \`entities.json\` |`)
|
|
249
|
+
p(`| scenarios | ${scenarioCount} | \`workflows.json[].scenarios\` |`)
|
|
250
|
+
p('')
|
|
251
|
+
p(`**Not built:** ${dropped.length} dropped artifacts · **Blocked:** ${blockedCommands.length + blockedQueries.length} concepts behind ${decisions.length} open decisions.`)
|
|
252
|
+
p('')
|
|
253
|
+
|
|
254
|
+
if (resolved.length) {
|
|
255
|
+
p('## Decisions already taken')
|
|
256
|
+
p('')
|
|
257
|
+
p('Answered at the gate and recorded in the rebuild\'s knowledge base. Each is a')
|
|
258
|
+
p('**deliberate behaviour change** and belongs in the parity report.')
|
|
259
|
+
p('')
|
|
260
|
+
for (const d of resolved) p(`- ${(d.blockedConcepts ?? []).join(', ')} — _${d.question.split('.')[0]}._`)
|
|
261
|
+
p('')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
p('## Blocked by an open decision')
|
|
265
|
+
p('')
|
|
266
|
+
if (decisions.length === 0) {
|
|
267
|
+
p('_None — the gate is clear._')
|
|
268
|
+
} else {
|
|
269
|
+
p('These cannot be built until the question is answered. They block **their own')
|
|
270
|
+
p('domains only** — every other slice proceeds.')
|
|
271
|
+
p('')
|
|
272
|
+
for (const d of decisions) {
|
|
273
|
+
p(`- **${(d.blockedConcepts ?? []).join(', ') || '(unscoped)'}**`)
|
|
274
|
+
p(` <br>${d.question}`)
|
|
275
|
+
if (d.options) p(` <br>_Options:_ ${d.options.join(' · ')}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
p('')
|
|
279
|
+
|
|
280
|
+
p('## Scheduled tasks (`wireScheduler`)')
|
|
281
|
+
p('')
|
|
282
|
+
p('| Workflow | Trigger |')
|
|
283
|
+
p('|---|---|')
|
|
284
|
+
for (const w of schedulers) p(`| ${w.name} | ${(w.trigger ?? '').replace(/\|/g, '\\|')} |`)
|
|
285
|
+
p('')
|
|
286
|
+
|
|
287
|
+
if (queueWorkers.length || ingress.length || pikkuWorkflows.length || eventConsumers.length) {
|
|
288
|
+
p('## Other system wiring')
|
|
289
|
+
p('')
|
|
290
|
+
p('| Workflow | Becomes |')
|
|
291
|
+
p('|---|---|')
|
|
292
|
+
for (const w of [...ingress, ...queueWorkers, ...eventConsumers, ...pikkuWorkflows])
|
|
293
|
+
p(`| ${w.name} | \`${classifyWorkflow(w)}\` |`)
|
|
294
|
+
p('')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
p('## Journeys → scenarios')
|
|
298
|
+
p('')
|
|
299
|
+
p(`${journeys.length} user/admin journeys carrying ${scenarioCount} scenarios excavated from the`)
|
|
300
|
+
p('legacy test suite. These are **not** `pikkuWorkflowFunc`s — they are sequences a')
|
|
301
|
+
p('person drives through the UI, and they become scenario tests.')
|
|
302
|
+
p('')
|
|
303
|
+
p('| Journey | Kind | Scenarios |')
|
|
304
|
+
p('|---|---|---:|')
|
|
305
|
+
for (const w of journeys) p(`| ${w.name} | ${w.kind} | ${(w.scenarios ?? []).length} |`)
|
|
306
|
+
p('')
|
|
307
|
+
|
|
308
|
+
p('## Per domain')
|
|
309
|
+
p('')
|
|
310
|
+
p('| Domain | Tables | `pikkuFunc` | readonly | permissions | events | blocked |')
|
|
311
|
+
p('|---|---:|---:|---:|---:|---:|---:|')
|
|
312
|
+
for (const d of domains) {
|
|
313
|
+
const n = d.name
|
|
314
|
+
const b = (cmdByDomain.get(n) ?? []).filter((c) => isBlocked(c.name, n)).length +
|
|
315
|
+
(qryByDomain.get(n) ?? []).filter((q) => isBlocked(q.name, n)).length
|
|
316
|
+
p(
|
|
317
|
+
`| ${n} | ${(entByDomain.get(n) ?? []).length} | ${(cmdByDomain.get(n) ?? []).length} | ${(qryByDomain.get(n) ?? []).length} | ${(polByDomain.get(n) ?? []).length} | ${(evtByDomain.get(n) ?? []).length} | ${b || ''} |`
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
p('')
|
|
321
|
+
|
|
322
|
+
p('## Addon candidates')
|
|
323
|
+
p('')
|
|
324
|
+
p('Advisory. A capability earns an addon when it is self-contained, reused across')
|
|
325
|
+
p('domains, and has a clear service seam — which is what a critical/hard-to-replace')
|
|
326
|
+
p('integration usually is. The call is yours.')
|
|
327
|
+
p('')
|
|
328
|
+
p('| Integration | Importance | Replace |')
|
|
329
|
+
p('|---|---|---|')
|
|
330
|
+
for (const i of addonCandidates)
|
|
331
|
+
p(`| ${i.name} | ${i.importance ?? ''} | ${i.replacementDifficulty ?? ''} |`)
|
|
332
|
+
p('')
|
|
333
|
+
|
|
334
|
+
if (feComponents.length) {
|
|
335
|
+
p('## Frontend')
|
|
336
|
+
p('')
|
|
337
|
+
p(`- **${cheapComponents.length}** components are a cheap re-expression in Mantine (standard / composition / restyle).`)
|
|
338
|
+
p(`- **${customLogic.length}** carry \`custom-logic\` and must be **ported**. This is the real frontend project.`)
|
|
339
|
+
p(`- **${feRoutes.length}** routes → TanStack routes; their \`dataFrom\` names are already the function names above.`)
|
|
340
|
+
p('')
|
|
341
|
+
if (customLogic.length) {
|
|
342
|
+
p('| custom-logic component | What must survive |')
|
|
343
|
+
p('|---|---|')
|
|
344
|
+
for (const c of customLogic)
|
|
345
|
+
p(`| ${c.name} | ${(c.customLogic ?? '').replace(/\|/g, '\\|').slice(0, 110)} |`)
|
|
346
|
+
p('')
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (interfaces.length) {
|
|
351
|
+
p('## Interfaces')
|
|
352
|
+
p('')
|
|
353
|
+
p('| Channel | Audience | Status |')
|
|
354
|
+
p('|---|---|---|')
|
|
355
|
+
for (const i of interfaces) p(`| ${i.kind} | ${i.audience ?? ''} | ${i.status ?? ''} |`)
|
|
356
|
+
p('')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
p('## Deliberately not built')
|
|
360
|
+
p('')
|
|
361
|
+
p('| Artifact | Why |')
|
|
362
|
+
p('|---|---|')
|
|
363
|
+
for (const d of dropped)
|
|
364
|
+
p(`| \`${d.path ?? d.file}\` | ${(d.reason ?? '').replace(/\|/g, '\\|').slice(0, 120)} |`)
|
|
365
|
+
p('')
|
|
366
|
+
|
|
367
|
+
console.log(out.join('\n'))
|