@voxgig/sdkgen 4.15.0 → 4.17.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/README.md +8 -0
- package/bin/voxgig-sdkgen +2 -2
- package/dist/action/action.js +2 -2
- package/dist/action/action.js.map +1 -1
- package/dist/action/check.js +9 -8
- package/dist/action/check.js.map +1 -1
- package/dist/action/dispatch.js +4 -4
- package/dist/action/dispatch.js.map +1 -1
- package/dist/action/doctor.js +16 -15
- package/dist/action/doctor.js.map +1 -1
- package/dist/action/edition.d.ts +4 -0
- package/dist/action/{docs.js → edition.js} +57 -54
- package/dist/action/edition.js.map +1 -0
- package/dist/action/kind.d.ts +1 -2
- package/dist/action/kind.js +22 -38
- package/dist/action/kind.js.map +1 -1
- package/dist/action/package.js +17 -16
- package/dist/action/package.js.map +1 -1
- package/dist/action/resolve.d.ts +3 -1
- package/dist/action/resolve.js +29 -11
- package/dist/action/resolve.js.map +1 -1
- package/dist/admin/status.d.ts +43 -0
- package/dist/admin/status.js +161 -0
- package/dist/admin/status.js.map +1 -0
- package/dist/cmp/Test.js +11 -0
- package/dist/cmp/Test.js.map +1 -1
- package/dist/helpers/kindCollection.d.ts +1 -0
- package/dist/helpers/kindCollection.js +14 -0
- package/dist/helpers/kindCollection.js.map +1 -0
- package/dist/helpers/modelcheck.d.ts +1 -1
- package/dist/helpers/modelcheck.js +13 -3
- package/dist/helpers/modelcheck.js.map +1 -1
- package/dist/helpers/shipped.js +2 -3
- package/dist/helpers/shipped.js.map +1 -1
- package/dist/helpers/testPolicy.d.ts +1 -1
- package/dist/helpers/testPolicy.js +6 -37
- package/dist/helpers/testPolicy.js.map +1 -1
- package/dist/sdkgen.d.ts +7 -1
- package/dist/sdkgen.js +146 -89
- package/dist/sdkgen.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utility.js +9 -1
- package/dist/utility.js.map +1 -1
- package/model/sdkgen.aon +5 -52
- package/package.json +1 -1
- package/project/.sdk/src/cmp/go/Config_go.ts +4 -4
- package/project/.sdk/src/cmp/go/utility_go.ts +10 -2
- package/project/.sdk/src/cmp/js/Package_js.ts +5 -1
- package/project/.sdk/src/cmp/js/TestDirect_js.ts +11 -7
- package/project/.sdk/src/cmp/js/TestEntity_js.ts +16 -2
- package/project/.sdk/src/cmp/js/TestLive_js.ts +32 -0
- package/project/.sdk/src/cmp/js/Test_js.ts +2 -0
- package/project/.sdk/src/cmp/js/fragment/Direct.test.fragment.js +1 -1
- package/project/.sdk/src/cmp/js/fragment/Entity.test.fragment.js +4 -2
- package/project/.sdk/src/cmp/php/ReadmeExamplesTest_php.ts +30 -9
- package/project/.sdk/src/cmp/php/ReadmeHowto_php.ts +8 -3
- package/project/.sdk/src/cmp/php/ReadmeQuick_php.ts +5 -4
- package/project/.sdk/src/cmp/php/ReadmeTopQuick_php.ts +2 -2
- package/project/.sdk/src/cmp/ts/Package_ts.ts +5 -1
- package/project/.sdk/src/cmp/ts/ReadmeHowto_ts.ts +5 -0
- package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +15 -11
- package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +13 -16
- package/project/.sdk/src/cmp/ts/TestLive_ts.ts +32 -0
- package/project/.sdk/src/cmp/ts/Test_ts.ts +2 -0
- package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +1 -0
- package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +2 -0
- package/project/.sdk/tm/csharp/test/feature/secrets/SecretsFeatureTest.cs +9 -2
- package/project/.sdk/tm/js/test/live-contract.js +183 -0
- package/project/.sdk/tm/js/test/live-entity.js +169 -0
- package/project/.sdk/tm/js/test/live-runner.js +129 -0
- package/project/.sdk/tm/js/test/live-scenarios.js +142 -0
- package/project/.sdk/tm/js/test/utility.js +84 -0
- package/project/.sdk/tm/ts/test/live-contract.ts +104 -0
- package/project/.sdk/tm/ts/test/live-entity.ts +142 -0
- package/project/.sdk/tm/ts/test/live-runner.ts +166 -0
- package/project/.sdk/tm/ts/test/live-scenarios.ts +112 -0
- package/project/.sdk/tm/ts/test/utility.ts +1 -2
- package/project/sdkgen-package.json +1 -1
- package/src/action/action.ts +2 -2
- package/src/action/check.ts +3 -2
- package/src/action/dispatch.ts +4 -4
- package/src/action/doctor.ts +10 -9
- package/src/action/{docs.ts → edition.ts} +56 -53
- package/src/action/kind.ts +12 -33
- package/src/action/package.ts +4 -3
- package/src/action/resolve.ts +26 -2
- package/src/admin/status.ts +124 -0
- package/src/cmp/Test.ts +13 -1
- package/src/helpers/kindCollection.ts +11 -0
- package/src/helpers/modelcheck.ts +10 -3
- package/src/helpers/shipped.ts +2 -3
- package/src/helpers/testPolicy.ts +7 -45
- package/src/sdkgen.ts +189 -93
- package/src/utility.ts +8 -2
- package/dist/action/docs.d.ts +0 -4
- package/dist/action/docs.js.map +0 -1
- package/dist/cmp/Docs.d.ts +0 -4
- package/dist/cmp/Docs.js +0 -98
- package/dist/cmp/Docs.js.map +0 -1
- package/dist/cmp/ExternalDocs.d.ts +0 -2
- package/dist/cmp/ExternalDocs.js +0 -43
- package/dist/cmp/ExternalDocs.js.map +0 -1
- package/src/cmp/Docs.ts +0 -125
- package/src/cmp/ExternalDocs.ts +0 -54
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { LiveBlocked, runLiveSteps, assertLiveReport, createLiveTransport } from './live-runner'
|
|
3
|
+
import { requestContract, synthesizeInput, validateContract } from './live-contract'
|
|
4
|
+
import { liveClientOptions, liveDelayMs, isControlSkipped } from './utility'
|
|
5
|
+
|
|
6
|
+
const at = (value: any, path?: string): any => (path || '').split('.').filter(Boolean).reduce((v, k) => v?.[k], value)
|
|
7
|
+
export function resolveRecipe(recipe: any, values: Map<string, any>): any {
|
|
8
|
+
if (Array.isArray(recipe)) return recipe.map(v => resolveRecipe(v, values))
|
|
9
|
+
if (recipe && typeof recipe === 'object') {
|
|
10
|
+
if (recipe.from) {
|
|
11
|
+
if (!values.has(recipe.from)) throw new LiveBlocked('Missing recipe output: ' + recipe.from)
|
|
12
|
+
let value = values.get(recipe.from)
|
|
13
|
+
if (recipe.where) {
|
|
14
|
+
if (!Array.isArray(value)) throw new LiveBlocked('Discovery output is not a list')
|
|
15
|
+
value = value.find((item: any) => Object.entries(recipe.where).every(([k, v]) => at(item, k) === v) &&
|
|
16
|
+
(!recipe.related || (values.get(recipe.related.from) || []).some((other: any) =>
|
|
17
|
+
at(item, recipe.related.local) === at(other, recipe.related.foreign) &&
|
|
18
|
+
Object.entries(recipe.related.where || {}).every(([k, v]) => at(other, k) === v))))
|
|
19
|
+
}
|
|
20
|
+
value = at(value, recipe.path)
|
|
21
|
+
if (value === undefined || value === null) throw new LiveBlocked('Discovery found no compatible value')
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
return Object.fromEntries(Object.entries(recipe).map(([k, v]) => [k, resolveRecipe(v, values)]))
|
|
25
|
+
}
|
|
26
|
+
return recipe
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function recipeNeeds(value: any): string[] {
|
|
30
|
+
if (!value || typeof value !== 'object') return []
|
|
31
|
+
return [...new Set([...(typeof value.from === 'string' ? [value.from] : []), ...Object.values(value).flatMap(recipeNeeds)])]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runLiveScenarios(SDK: any, plan: any[], envPrefix: string, liveDefaults: any = {}) {
|
|
35
|
+
const transport = createLiveTransport()
|
|
36
|
+
const steps = plan.map(point => {
|
|
37
|
+
const hint = point.facts.live || {}
|
|
38
|
+
const control = isControlSkipped('entityOp', point.entity + '.' + point.op, 'live')
|
|
39
|
+
return { role: hint.auth || (point.facts.security?.length === 0 || point.facts.securitySource === 'unspecified' ? 'public' : 'account'), retention: hint.retention, id: hint.id || point.id, needs: [...new Set([...(hint.needs || []), ...recipeNeeds(hint.input), ...recipeNeeds(hint.credential), ...recipeNeeds(hint.assert)])] as string[],
|
|
40
|
+
cleanup: !!hint.cleanup,
|
|
41
|
+
excluded: control.skip ? control.reason || 'Excluded by test control' : hint.excluded,
|
|
42
|
+
run: async (ctx: any) => {
|
|
43
|
+
transport.enter(ctx)
|
|
44
|
+
if (point.contractVersion && point.contractVersion !== 1) throw new LiveBlocked('Unsupported operation contract version')
|
|
45
|
+
if (point.op === 'remove' || hint.cleanup) {
|
|
46
|
+
const owned = recipeNeeds(hint.input).some(id => plan.some(source =>
|
|
47
|
+
(source.facts.live?.id || source.id) === id && source.entity === point.entity && source.op === 'create'))
|
|
48
|
+
if (!owned) throw new LiveBlocked('Cleanup input is not bound to a resource created by this run')
|
|
49
|
+
}
|
|
50
|
+
if (!point.reachable) throw new LiveBlocked('Point selector is indistinguishable; add a guide action')
|
|
51
|
+
const request = requestContract(point.facts)
|
|
52
|
+
const explicit = hint.input === undefined ? request.example : resolveRecipe(hint.input, ctx.values)
|
|
53
|
+
let input = request.schema ? synthesizeInput(request.schema, explicit) : explicit ?? {}
|
|
54
|
+
for (const kind of ['params', 'query', 'header', 'cookie']) for (const arg of point.args?.[kind] || []) {
|
|
55
|
+
if (arg.reqd && input[arg.name] === undefined) {
|
|
56
|
+
if (arg.example === undefined) throw new LiveBlocked('Missing required argument: ' + arg.name)
|
|
57
|
+
input[arg.name] = arg.example
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const role = hint.auth || (point.facts.security?.length === 0 || point.facts.securitySource === 'unspecified' ? 'public' : 'account')
|
|
61
|
+
let apikey = role === 'public' ? null : role === 'issued' ? resolveRecipe(hint.credential, ctx.values) : process.env[envPrefix + '_APIKEY']
|
|
62
|
+
if (role === 'issued' && (typeof apikey !== 'string' || !apikey)) throw new LiveBlocked('Issued credential unavailable')
|
|
63
|
+
let wire: any
|
|
64
|
+
let wireSchema: any
|
|
65
|
+
const options = liveClientOptions()
|
|
66
|
+
const client = new SDK({ ...options, ...liveDefaults, apikey,
|
|
67
|
+
feature: { ...options.feature, test: { active: false }, retry: { active: false },
|
|
68
|
+
secrets: { ...options.feature?.secrets, active: role === 'account' && (!apikey || options.feature?.secrets?.active === true) } },
|
|
69
|
+
system: { ...options.system, fetch: async (url: any, init: any) => {
|
|
70
|
+
const headers = new Headers(init?.headers)
|
|
71
|
+
if (role === 'public') assert(!headers.has('authorization'), 'Public request carries authentication')
|
|
72
|
+
else if (!headers.get('authorization')) throw new LiveBlocked('Credential unavailable for ' + role + ' request')
|
|
73
|
+
const response = await transport.fetch(url, { ...init, redirect: 'error' })
|
|
74
|
+
const expectedPath = point.path.replace(/\{([^}]+)\}/g, (_: string, name: string) => encodeURIComponent(input[point.rename?.param?.[name] || name] ?? input[name]))
|
|
75
|
+
if (point.kind === 'graphql') {
|
|
76
|
+
const payload = JSON.parse(init.body)
|
|
77
|
+
assert.equal(payload.query, point.graphql.doc, 'Entity selected wrong GraphQL operation')
|
|
78
|
+
} else assert.equal(new URL(String(url)).pathname, expectedPath, 'Entity selected wrong route')
|
|
79
|
+
assert.equal(init?.method || 'GET', point.method)
|
|
80
|
+
assert(response.status >= 200 && response.status < 300, 'Unsuccessful HTTP response')
|
|
81
|
+
if (point.kind === 'graphql') wire = await response.clone().json()
|
|
82
|
+
const def = point.facts.responses?.[response.status] || point.facts.responses?.[String(response.status)[0] + 'XX'] || point.facts.responses?.default
|
|
83
|
+
if (point.facts.responses && !def) throw new Error('Undeclared response status')
|
|
84
|
+
if (def) {
|
|
85
|
+
const schema = def.content?.['application/json']?.schema ?? def.schema
|
|
86
|
+
if (schema && response.status !== 204) { wireSchema = schema; wire = await response.clone().json() }
|
|
87
|
+
}
|
|
88
|
+
return response
|
|
89
|
+
} } })
|
|
90
|
+
if (point.action) input = { ...input, $action: point.action }
|
|
91
|
+
assert(JSON.stringify(input).length <= 1024 * 1024, 'Request exceeds test payload limit')
|
|
92
|
+
const result = await client[point.accessor]()[point.op](input)
|
|
93
|
+
const data = Array.isArray(result) ? result.map(item => { assert.equal(typeof item?.data, 'function'); return item.data() }) : (assert.equal(typeof result?.data, 'function'), result.data())
|
|
94
|
+
ctx.publish(data)
|
|
95
|
+
if (wireSchema) validateContract(wireSchema, wire, 'response')
|
|
96
|
+
if (Array.isArray(wire?.errors) && wire.errors.length) throw new Error('GraphQL operation reported errors')
|
|
97
|
+
if (wire?.success === false) throw new Error('API reported unsuccessful operation')
|
|
98
|
+
const checks = resolveRecipe(hint.assert || {}, ctx.values)
|
|
99
|
+
for (const [path, expected] of Object.entries(checks.equal || {})) assert.deepEqual(at(data, path), expected)
|
|
100
|
+
for (const path of checks.nonempty || []) assert(at(data, path)?.length > 0, 'Empty required output')
|
|
101
|
+
if (checks.vectors) {
|
|
102
|
+
const vectors = at(data, checks.vectors.path)
|
|
103
|
+
assert(Array.isArray(vectors) && vectors.length === checks.vectors.count)
|
|
104
|
+
for (const vector of vectors) assert(Array.isArray(vector) && vector.length === checks.vectors.dimension && vector.every((n: any) => typeof n === 'number' && Number.isFinite(n)), 'Invalid vector shape')
|
|
105
|
+
}
|
|
106
|
+
} }
|
|
107
|
+
})
|
|
108
|
+
const report = await runLiveSteps(steps, { delayMs: liveDelayMs(), report: result => console.log('LIVE STEP ' + JSON.stringify(result)) })
|
|
109
|
+
console.log('LIVE SUMMARY ' + JSON.stringify(report))
|
|
110
|
+
assertLiveReport(report)
|
|
111
|
+
return report
|
|
112
|
+
}
|
|
@@ -161,8 +161,7 @@ function skipIfMissingIds(t: any, setup: any, requiredKeys: string[]): boolean {
|
|
|
161
161
|
if (!setup.live) return false
|
|
162
162
|
const missing = requiredKeys.filter(k => null == setup.idmap?.[k])
|
|
163
163
|
if (missing.length > 0) {
|
|
164
|
-
|
|
165
|
-
return true
|
|
164
|
+
throw new Error(`Live test blocked: needs ${missing.join(', ')} via *_ENTID env var`)
|
|
166
165
|
}
|
|
167
166
|
return false
|
|
168
167
|
}
|
package/src/action/action.ts
CHANGED
|
@@ -102,7 +102,7 @@ function parseAddNames(args: any[]): string[] {
|
|
|
102
102
|
// `seed` IS THE UPGRADE PATH. A project scaffolded before a kind existed has
|
|
103
103
|
// no `model/<kind>/<kind>-index.aon` — every project alive today is in
|
|
104
104
|
// exactly that position for `docs` — and reading it unguarded made the FIRST
|
|
105
|
-
// `
|
|
105
|
+
// `edition add` in any existing project fail on ENOENT before it wrote anything.
|
|
106
106
|
//
|
|
107
107
|
// Seeded per call rather than defaulted for every kind: a missing
|
|
108
108
|
// `target-index.aon` in a scaffolded project is a broken project, and
|
|
@@ -136,7 +136,7 @@ function loadContent(
|
|
|
136
136
|
// indexes of the kinds that existed then. So a project scaffolded before a
|
|
137
137
|
// kind existed — which is every project alive today, for `docs` — never
|
|
138
138
|
// includes its index, and the item's model file is an orphan: it is on disk,
|
|
139
|
-
// `<kind>-index.aon` includes it, and NOTHING includes that. `main.kit.
|
|
139
|
+
// `<kind>-index.aon` includes it, and NOTHING includes that. `main.kit.doc.edition`
|
|
140
140
|
// is then absent from the compiled model, so `package list`, `package update`
|
|
141
141
|
// and `doctor` cannot see the item at all.
|
|
142
142
|
//
|
package/src/action/check.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kindCollection } from '../helpers/kindCollection'
|
|
1
2
|
// `package check` — the AUTHOR-side battery. Design §14.
|
|
2
3
|
//
|
|
3
4
|
// WHAT IT IS FOR
|
|
@@ -308,11 +309,11 @@ function checkDefinition(
|
|
|
308
309
|
// `main: kit: target: go:` installs an item the consumer's model never
|
|
309
310
|
// sees — the exact mistake a package author makes copying a bundled
|
|
310
311
|
// target as a starting point.
|
|
311
|
-
const declared = strict.model
|
|
312
|
+
const declared = kindCollection(strict.model, kind)?.[name]
|
|
312
313
|
|
|
313
314
|
if (null == declared || 'object' !== typeof declared) {
|
|
314
315
|
found.push(at('error', 'model-key-missing',
|
|
315
|
-
'declares no `main: kit: ' + kind + ': ' + name + ':` block — the file ' +
|
|
316
|
+
'declares no `main: kit: ' + (kind === 'edition' ? 'doc: edition' : kind) + ': ' + name + ':` block — the file ' +
|
|
316
317
|
'is installed and included under its own name, so nothing it declares ' +
|
|
317
318
|
'under another name is reachable'))
|
|
318
319
|
}
|
package/src/action/dispatch.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// WHAT `voxgig-sdkgen <action> …` CAN BE.
|
|
2
2
|
//
|
|
3
3
|
// Built FROM THE KIND REGISTRY rather than hand-listed, so registering a kind
|
|
4
|
-
// is the only edit a new kind needs — `
|
|
4
|
+
// is the only edit a new kind needs — `edition add …` costs no dispatch code
|
|
5
5
|
// (docs/design/sdkgen-packages.md §9). The `package` and `doctor` verbs are
|
|
6
6
|
// not kinds and are added beside it.
|
|
7
7
|
//
|
|
@@ -25,7 +25,7 @@ import { KINDS } from './kind'
|
|
|
25
25
|
|
|
26
26
|
import { action_target, target_add } from './target'
|
|
27
27
|
import { action_feature, feature_add } from './feature'
|
|
28
|
-
import {
|
|
28
|
+
import { action_edition, edition_add } from './edition'
|
|
29
29
|
import { action_doctor } from './doctor'
|
|
30
30
|
import { action_package, registerAdder } from './package'
|
|
31
31
|
|
|
@@ -41,7 +41,7 @@ const KIND_ACTIONS: Record<string, ActionFunc> = Object.assign(
|
|
|
41
41
|
Object.create(null), {
|
|
42
42
|
target: action_target,
|
|
43
43
|
feature: action_feature,
|
|
44
|
-
|
|
44
|
+
edition: action_edition,
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
|
|
@@ -49,7 +49,7 @@ const KIND_ACTIONS: Record<string, ActionFunc> = Object.assign(
|
|
|
49
49
|
// over. Registered rather than imported by `package.ts`, for the cycle above.
|
|
50
50
|
registerAdder('target', target_add)
|
|
51
51
|
registerAdder('feature', feature_add)
|
|
52
|
-
registerAdder('
|
|
52
|
+
registerAdder('edition', edition_add)
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
function actionMap(): Record<string, ActionFunc> {
|
package/src/action/doctor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kindCollection } from '../helpers/kindCollection'
|
|
1
2
|
// `voxgig-sdkgen doctor` — does this project's `.sdk/` still match the
|
|
2
3
|
// scaffold?
|
|
3
4
|
//
|
|
@@ -276,7 +277,7 @@ async function doctor(
|
|
|
276
277
|
|
|
277
278
|
const counts: Record<string, number> = {}
|
|
278
279
|
for (const kind of kinds) {
|
|
279
|
-
counts[kind] = Object.keys((model
|
|
280
|
+
counts[kind] = Object.keys(kindCollection(model, kind) ?? {}).length
|
|
280
281
|
}
|
|
281
282
|
|
|
282
283
|
log.info({ point: 'doctor-start', targets: counts.target ?? 0, ...counts })
|
|
@@ -303,7 +304,7 @@ async function doctor(
|
|
|
303
304
|
}
|
|
304
305
|
|
|
305
306
|
for (const kind of kinds) {
|
|
306
|
-
const items = Object.keys((model
|
|
307
|
+
const items = Object.keys(kindCollection(model, kind) ?? {}).sort()
|
|
307
308
|
|
|
308
309
|
for (const name of items) {
|
|
309
310
|
if (null != scope && !scope(kind, name)) {
|
|
@@ -321,8 +322,8 @@ async function doctor(
|
|
|
321
322
|
checkTarget(actx, source, report)
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
if ('
|
|
325
|
-
|
|
325
|
+
if ('edition' === kind) {
|
|
326
|
+
checkEdition(actx, source, report)
|
|
326
327
|
}
|
|
327
328
|
|
|
328
329
|
// Only an ACTIVE feature has source copied out; what an inactive one
|
|
@@ -433,7 +434,7 @@ function checkWiring(actx: ActionContext, report: DoctorReport) {
|
|
|
433
434
|
function resolveDeclared(
|
|
434
435
|
kind: string, name: string, actx: ActionContext,
|
|
435
436
|
): Source | undefined {
|
|
436
|
-
const declared: any = (actx.model
|
|
437
|
+
const declared: any = kindCollection(actx.model, kind)?.[name]
|
|
437
438
|
const ref = recordedRef(declared, name) || name
|
|
438
439
|
|
|
439
440
|
try {
|
|
@@ -659,8 +660,8 @@ function compareTrees(
|
|
|
659
660
|
// rather than being spelled a second time.
|
|
660
661
|
//
|
|
661
662
|
// The optional template tree is skipped when the SOURCE does not ship one:
|
|
662
|
-
// `
|
|
663
|
-
function
|
|
663
|
+
// `edition add` did not copy it, so the project is right not to have it.
|
|
664
|
+
function checkEdition(
|
|
664
665
|
actx: ActionContext, resolved: Source, report: DoctorReport,
|
|
665
666
|
) {
|
|
666
667
|
const fs = actx.fs()
|
|
@@ -669,8 +670,8 @@ function checkDocs(
|
|
|
669
670
|
const origname = resolved.origname
|
|
670
671
|
const aliased = name !== origname
|
|
671
672
|
|
|
672
|
-
const dest = kindTrees('
|
|
673
|
-
const from = kindTrees('
|
|
673
|
+
const dest = kindTrees('edition', name)
|
|
674
|
+
const from = kindTrees('edition', origname)
|
|
674
675
|
|
|
675
676
|
const trees: TreeCompare[] = dest.flatMap((tree: TreeDef, i: number) => {
|
|
676
677
|
const scaffold = Path.join(resolved.folder, ...from[i].path.split('/'))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { kindCollection } from '../helpers/kindCollection'
|
|
2
|
+
// `edition add <ref>` — the third kind. See edition/design/sdkgen-packages.md §20.
|
|
2
3
|
//
|
|
3
4
|
// WHAT A DOCS ITEM IS
|
|
4
5
|
//
|
|
@@ -12,11 +13,11 @@
|
|
|
12
13
|
//
|
|
13
14
|
// Three reasons, argued in §20.2 and worth restating where the code is:
|
|
14
15
|
//
|
|
15
|
-
// - a
|
|
16
|
-
// package table, per-language tabs — so a
|
|
16
|
+
// - a edition item's INPUT is the target collection — a page per SDK, the
|
|
17
|
+
// package table, per-language tabs — so a edition item inside
|
|
17
18
|
// `main.kit.target` would enumerate itself;
|
|
18
19
|
// - `action/feature.ts` fans out with `each(target, …)` and warns
|
|
19
|
-
// `feature-source-missing` per target with no source, so a
|
|
20
|
+
// `feature-source-missing` per target with no source, so a edition item in
|
|
20
21
|
// that collection would collect one warning per feature, forever
|
|
21
22
|
// (`srcfeature: false` does not help — that flag is read at generate time
|
|
22
23
|
// and never by the add-time fan-out);
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
// aliasing and the index come along unchanged — and the kind's trees, whose
|
|
31
32
|
// paths come from the registry rather than being spelled here.
|
|
32
33
|
//
|
|
33
|
-
// It does NOT fan out over targets, trim, or prune. A
|
|
34
|
+
// It does NOT fan out over targets, trim, or prune. A edition item reads the
|
|
34
35
|
// target collection at GENERATE time, not at add time: the opposite direction
|
|
35
36
|
// and the opposite moment from a feature's fan-out, so sharing that machinery
|
|
36
37
|
// would have been a false economy.
|
|
@@ -58,26 +59,26 @@ import { aliasCmpTree, pruneStaleTemplates } from './target'
|
|
|
58
59
|
import { parseAddNames, loadContent, ensureModelInclude } from './action'
|
|
59
60
|
|
|
60
61
|
|
|
61
|
-
// The PREFIX of a
|
|
62
|
+
// The PREFIX of a edition item's component tree (`src/cmp/edition/`), for the
|
|
62
63
|
// alias rewrite. Derived from the registry's declaration rather than written
|
|
63
|
-
// out a second time, so the two cannot disagree about where
|
|
64
|
+
// out a second time, so the two cannot disagree about where edition components
|
|
64
65
|
// live.
|
|
65
66
|
const NAME_MARK = '\u0001name\u0001'
|
|
66
67
|
|
|
67
68
|
function cmpBase(): string {
|
|
68
|
-
const cmp = kindTrees('
|
|
69
|
+
const cmp = kindTrees('edition', NAME_MARK)
|
|
69
70
|
.find((t: TreeDef) => 'none' === t.replace)
|
|
70
71
|
|
|
71
|
-
return null == cmp ? 'src/cmp/
|
|
72
|
+
return null == cmp ? 'src/cmp/edition/' : cmp.path.split(NAME_MARK)[0]
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
|
|
75
76
|
const CMD_MAP: any = Object.assign(Object.create(null), {
|
|
76
|
-
add:
|
|
77
|
+
add: cmd_edition_add,
|
|
77
78
|
})
|
|
78
79
|
|
|
79
80
|
|
|
80
|
-
async function
|
|
81
|
+
async function action_edition(
|
|
81
82
|
args: string[], actx: ActionContext,
|
|
82
83
|
): Promise<ActionResult> {
|
|
83
84
|
const cmdname = args[1]
|
|
@@ -85,7 +86,7 @@ async function action_docs(
|
|
|
85
86
|
|
|
86
87
|
if (null == cmd) {
|
|
87
88
|
throw new SdkGenError(
|
|
88
|
-
'Unknown
|
|
89
|
+
'Unknown edition cmd: ' + cmdname + ' (expected: ' +
|
|
89
90
|
Object.keys(CMD_MAP).sort().join(', ') + ')')
|
|
90
91
|
}
|
|
91
92
|
|
|
@@ -93,17 +94,19 @@ async function action_docs(
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
|
|
96
|
-
async function
|
|
97
|
+
async function cmd_edition_add(
|
|
97
98
|
args: string[], actx: ActionContext,
|
|
98
99
|
): Promise<ActionResult> {
|
|
99
|
-
return
|
|
100
|
+
return edition_add(parseAddNames(args), actx)
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
|
|
103
104
|
// Code API.
|
|
104
|
-
async function
|
|
105
|
-
|
|
105
|
+
async function edition_add(
|
|
106
|
+
edition: string[], actx: ActionContext,
|
|
106
107
|
): Promise<ActionResult> {
|
|
108
|
+
edition = edition.map(ref => !ref.includes('/') && !ref.includes('\\') &&
|
|
109
|
+
!kindCollection(actx.model, 'edition')[ref.split('~')[0]] ? '@voxgig/docgen/project/' + ref : ref)
|
|
107
110
|
const jostraca = actx.jostraca
|
|
108
111
|
|
|
109
112
|
const opts = {
|
|
@@ -112,9 +115,9 @@ async function docs_add(
|
|
|
112
115
|
log: actx.log.child({ cmp: 'jostraca' }),
|
|
113
116
|
meta: {
|
|
114
117
|
url: actx.url,
|
|
115
|
-
// Seeded: no project scaffolded before the
|
|
116
|
-
//
|
|
117
|
-
content: loadContent(actx, '
|
|
118
|
+
// Seeded: no project scaffolded before the edition kind existed has a
|
|
119
|
+
// edition index, and every project alive today is in that position.
|
|
120
|
+
content: loadContent(actx, 'edition', { edition: '# Docs\n' }),
|
|
118
121
|
},
|
|
119
122
|
model: actx.model,
|
|
120
123
|
// Per-call, never left to the Jostraca instance: `generate` runs its own
|
|
@@ -130,7 +133,7 @@ async function docs_add(
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
opts.log.info({
|
|
133
|
-
point: '
|
|
136
|
+
point: 'edition-start',
|
|
134
137
|
note: (actx.opts.dryrun ? '** DRY RUN **' : '')
|
|
135
138
|
})
|
|
136
139
|
|
|
@@ -138,26 +141,26 @@ async function docs_add(
|
|
|
138
141
|
//
|
|
139
142
|
// The write pass emits the definition and its index entry before it copies
|
|
140
143
|
// the trees, so a ref whose definition exists but whose required components
|
|
141
|
-
// do not left the command failed AND the project carrying a
|
|
144
|
+
// do not left the command failed AND the project carrying a edition item with
|
|
142
145
|
// no implementation — which the next model compile then reads as real.
|
|
143
146
|
// `package add` already validates a whole package up front for exactly this
|
|
144
|
-
// reason; a direct `
|
|
145
|
-
preflight(
|
|
147
|
+
// reason; a direct `edition add` needs the same guarantee.
|
|
148
|
+
preflight(edition, actx)
|
|
146
149
|
|
|
147
|
-
// The project's own model must INCLUDE the
|
|
150
|
+
// The project's own model must INCLUDE the edition index, or everything below
|
|
148
151
|
// is invisible: no project scaffolded before this kind existed includes it,
|
|
149
|
-
// and `main.kit.
|
|
150
|
-
ensureModelInclude(actx, '
|
|
152
|
+
// and `main.kit.edition` would simply be absent from the next compile.
|
|
153
|
+
ensureModelInclude(actx, 'edition')
|
|
151
154
|
|
|
152
155
|
// Into the IN-MEMORY model before anything reads it. Nothing recompiles
|
|
153
|
-
// `model/sdk.aontu` mid-process, so without this a second
|
|
156
|
+
// `model/sdk.aontu` mid-process, so without this a second edition item in the
|
|
154
157
|
// same command — and anything else later in it — behaves as if the first
|
|
155
158
|
// was never installed. One definition of what gets recorded, shared with
|
|
156
159
|
// `target add` and `package add`.
|
|
157
|
-
registerInstalled('
|
|
160
|
+
registerInstalled('edition', edition, actx)
|
|
158
161
|
|
|
159
162
|
const jres = await jostraca.generate(opts, () =>
|
|
160
|
-
|
|
163
|
+
EditionRoot({ edition, actx }))
|
|
161
164
|
|
|
162
165
|
return { jres }
|
|
163
166
|
}
|
|
@@ -166,13 +169,13 @@ async function docs_add(
|
|
|
166
169
|
// Resolve every ref and check every REQUIRED tree, throwing before anything
|
|
167
170
|
// is written. Resolution itself is the other half: a ref that names no
|
|
168
171
|
// definition fails here rather than partway through the pass.
|
|
169
|
-
function preflight(
|
|
172
|
+
function preflight(edition: string[], actx: ActionContext) {
|
|
170
173
|
const fs = actx.fs()
|
|
171
174
|
|
|
172
|
-
for (const ref of
|
|
173
|
-
const source = resolveKind(ref, '
|
|
175
|
+
for (const ref of edition) {
|
|
176
|
+
const source = resolveKind(ref, 'edition', actx as any)
|
|
174
177
|
|
|
175
|
-
for (const tree of kindTrees('
|
|
178
|
+
for (const tree of kindTrees('edition', source.origname)) {
|
|
176
179
|
if (!tree.required) {
|
|
177
180
|
continue
|
|
178
181
|
}
|
|
@@ -182,7 +185,7 @@ function preflight(docs: string[], actx: ActionContext) {
|
|
|
182
185
|
if (!fs.existsSync(from)) {
|
|
183
186
|
throw new SdkGenError(
|
|
184
187
|
'Docs ' + source.name + ': required tree not found: ' + from +
|
|
185
|
-
'\n a
|
|
188
|
+
'\n a edition item needs its components (' + tree.path +
|
|
186
189
|
'); nothing has been written')
|
|
187
190
|
}
|
|
188
191
|
}
|
|
@@ -190,8 +193,8 @@ function preflight(docs: string[], actx: ActionContext) {
|
|
|
190
193
|
}
|
|
191
194
|
|
|
192
195
|
|
|
193
|
-
const
|
|
194
|
-
const { ctx$,
|
|
196
|
+
const EditionRoot = cmp(function EditionRoot(props: any) {
|
|
197
|
+
const { ctx$, edition } = props
|
|
195
198
|
const { log } = ctx$
|
|
196
199
|
|
|
197
200
|
Project({}, () => {
|
|
@@ -200,29 +203,29 @@ const DocsRoot = cmp(function DocsRoot(props: any) {
|
|
|
200
203
|
// carry all of them.
|
|
201
204
|
const dnames: string[] = []
|
|
202
205
|
|
|
203
|
-
each(
|
|
206
|
+
each(edition, (n: any) => {
|
|
204
207
|
const dref = n.val$
|
|
205
208
|
|
|
206
|
-
log.info({ point: '
|
|
209
|
+
log.info({ point: 'edition-build', edition: dref, note: dref })
|
|
207
210
|
|
|
208
211
|
// The shared spine: a BARE name resolves against what the model
|
|
209
|
-
// RECORDS, so a
|
|
212
|
+
// RECORDS, so a edition item installed from a package resolves back to
|
|
210
213
|
// that package on its next add rather than to the bundled scaffold.
|
|
211
|
-
const source = resolveKind(dref, '
|
|
214
|
+
const source = resolveKind(dref, 'edition', ctx$)
|
|
212
215
|
|
|
213
216
|
dnames.push(source.name)
|
|
214
217
|
|
|
215
218
|
log.info({
|
|
216
|
-
point: '
|
|
219
|
+
point: 'edition-name', edition: source.name, folder: source.folder, ref: dref,
|
|
217
220
|
note: source.name +
|
|
218
221
|
(source.name !== source.origname ?
|
|
219
222
|
' (from ' + source.origname + ')' : '') +
|
|
220
223
|
' from:' + source.folder
|
|
221
224
|
})
|
|
222
225
|
|
|
223
|
-
Folder({ name: 'model/
|
|
224
|
-
ctx$, kind: '
|
|
225
|
-
content: ctx$.meta.content.
|
|
226
|
+
Folder({ name: 'model/edition' }, () => kindModel({
|
|
227
|
+
ctx$, kind: 'edition', source, names: dnames,
|
|
228
|
+
content: ctx$.meta.content.edition_index,
|
|
226
229
|
}))
|
|
227
230
|
|
|
228
231
|
// Both ends of every tree come from the registry's ONE declaration,
|
|
@@ -230,8 +233,8 @@ const DocsRoot = cmp(function DocsRoot(props: any) {
|
|
|
230
233
|
// the installed one. Deriving the source path by substituting inside
|
|
231
234
|
// the destination path would corrupt any item whose name also appears
|
|
232
235
|
// in the fixed part of the path.
|
|
233
|
-
const dest = kindTrees('
|
|
234
|
-
const from = kindTrees('
|
|
236
|
+
const dest = kindTrees('edition', source.name)
|
|
237
|
+
const from = kindTrees('edition', source.origname)
|
|
235
238
|
|
|
236
239
|
dest.forEach((tree: TreeDef, i: number) => {
|
|
237
240
|
// Copy only ADDS and overwrites. A newer version of the package that
|
|
@@ -252,7 +255,7 @@ const DocsRoot = cmp(function DocsRoot(props: any) {
|
|
|
252
255
|
copyTree(ctx$, source, tree, from[i].path)
|
|
253
256
|
})
|
|
254
257
|
|
|
255
|
-
log.info({ point: '
|
|
258
|
+
log.info({ point: 'edition-done', edition: source.name, note: source.name })
|
|
256
259
|
})
|
|
257
260
|
})
|
|
258
261
|
})
|
|
@@ -261,7 +264,7 @@ const DocsRoot = cmp(function DocsRoot(props: any) {
|
|
|
261
264
|
// One tree, copied from the origin path to the installed one.
|
|
262
265
|
//
|
|
263
266
|
// An optional tree the source does not ship is simply not copied — that is
|
|
264
|
-
// what `required: false` means, and a
|
|
267
|
+
// what `required: false` means, and a edition item whose every byte is generated
|
|
265
268
|
// legitimately has no template tree.
|
|
266
269
|
function copyTree(ctx$: any, source: any, tree: TreeDef, frompath: string) {
|
|
267
270
|
const fs = ctx$.fs()
|
|
@@ -274,7 +277,7 @@ function copyTree(ctx$: any, source: any, tree: TreeDef, frompath: string) {
|
|
|
274
277
|
}
|
|
275
278
|
|
|
276
279
|
ctx$.log.info({
|
|
277
|
-
point: '
|
|
280
|
+
point: 'edition-tree-absent', edition: source.name, tree: tree.path, from,
|
|
278
281
|
note: source.name + ': the source ships no ' + frompath +
|
|
279
282
|
', nothing to copy'
|
|
280
283
|
})
|
|
@@ -282,8 +285,8 @@ function copyTree(ctx$: any, source: any, tree: TreeDef, frompath: string) {
|
|
|
282
285
|
}
|
|
283
286
|
|
|
284
287
|
// An ALIASED component tree cannot be copied verbatim: components are
|
|
285
|
-
// dispatched by the convention `cmp/
|
|
286
|
-
// the origin suffix resolve nothing — `
|
|
288
|
+
// dispatched by the convention `cmp/edition/<n>/Main_<n>`, so files keeping
|
|
289
|
+
// the origin suffix resolve nothing — `Main_summary.ts` is invisible to a
|
|
287
290
|
// lookup for `Main_portal`. Same rule as a target's, so the same function
|
|
288
291
|
// does it; jostraca's tree Copy has no per-entry rename hook, which is why
|
|
289
292
|
// an aliased tree is emitted file by file.
|
|
@@ -305,6 +308,6 @@ function copyTree(ctx$: any, source: any, tree: TreeDef, frompath: string) {
|
|
|
305
308
|
|
|
306
309
|
|
|
307
310
|
export {
|
|
308
|
-
|
|
309
|
-
|
|
311
|
+
action_edition,
|
|
312
|
+
edition_add,
|
|
310
313
|
}
|
package/src/action/kind.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kindCollection } from '../helpers/kindCollection'
|
|
1
2
|
// KINDS: the things an `add` can install.
|
|
2
3
|
//
|
|
3
4
|
// `target` and `feature` are two of them, `docs` and others are meant to
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
// are genuinely different work, not the same work with different strings, and
|
|
21
22
|
// pretending otherwise would buy generality nobody can use.
|
|
22
23
|
|
|
24
|
+
|
|
23
25
|
import Path from 'node:path'
|
|
24
26
|
|
|
25
27
|
import { File, Copy, Content, template } from 'jostraca'
|
|
@@ -30,7 +32,7 @@ import { SdkGenError } from '../utility'
|
|
|
30
32
|
|
|
31
33
|
import { provenanceReplace } from '../helpers/stdrep'
|
|
32
34
|
|
|
33
|
-
import { resolveSource } from './resolve'
|
|
35
|
+
import { resolveSource, recordedRef, isBare } from './resolve'
|
|
34
36
|
import type { Source } from './resolve'
|
|
35
37
|
|
|
36
38
|
import { UpdateIndex } from './action'
|
|
@@ -92,7 +94,7 @@ type TreeDef = {
|
|
|
92
94
|
|
|
93
95
|
// Rewrite the ITEM KEY in a copied model file, for an aliased install.
|
|
94
96
|
//
|
|
95
|
-
// Parameterised by kind because `main: kit:
|
|
97
|
+
// Parameterised by kind because `main: kit: doc: edition: <n>:` is the same rewrite
|
|
96
98
|
// with a different word, and a second copy of this regex is exactly the
|
|
97
99
|
// same-rule-written-twice defect the registry exists to prevent.
|
|
98
100
|
//
|
|
@@ -156,21 +158,21 @@ const KINDS: Record<string, KindDef> = Object.assign(Object.create(null), {
|
|
|
156
158
|
|
|
157
159
|
// DOCS — the third kind. See docs/design/sdkgen-packages.md §20.
|
|
158
160
|
//
|
|
159
|
-
// Its trees are NESTED under the kind name (`src/cmp/
|
|
161
|
+
// Its trees are NESTED under the kind name (`src/cmp/edition/<n>`, not
|
|
160
162
|
// `src/cmp/<n>`) so a docs item and a target may share a name without
|
|
161
163
|
// sharing a directory. Everything that composes those paths takes them
|
|
162
164
|
// from here.
|
|
163
165
|
//
|
|
164
|
-
// `tm/
|
|
166
|
+
// `tm/edition/{name}` is NOT required: a docs item whose every emitted byte
|
|
165
167
|
// depends on the API — a catalogue entry, a config file — legitimately
|
|
166
168
|
// ships no template tree, while a static site needs one. So it is
|
|
167
169
|
// copy-if-present, and `package check` does not demand it.
|
|
168
|
-
|
|
169
|
-
name: '
|
|
170
|
-
rename: aliasModelKey('
|
|
170
|
+
edition: {
|
|
171
|
+
name: 'edition', alias: true, ownedWhenAliased: true,
|
|
172
|
+
rename: aliasModelKey('edition'),
|
|
171
173
|
trees: [
|
|
172
|
-
{ path: 'src/cmp/
|
|
173
|
-
{ path: 'tm/
|
|
174
|
+
{ path: 'src/cmp/edition/{name}', replace: 'none', required: true },
|
|
175
|
+
{ path: 'tm/edition/{name}', replace: 'template', required: false },
|
|
174
176
|
],
|
|
175
177
|
},
|
|
176
178
|
})
|
|
@@ -208,12 +210,7 @@ function kindDef(kind: string): KindDef {
|
|
|
208
210
|
// source.
|
|
209
211
|
function resolveKind(ref: string, kind: string, ctx$: any): Source {
|
|
210
212
|
const def = kindDef(kind)
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
const declared: any = model?.main?.[KIT]?.[kind]?.[ref]
|
|
214
|
-
const recorded = (isBare(ref) && recordedRef(declared, ref)) || ref
|
|
215
|
-
|
|
216
|
-
const source = resolveSource(recorded, kind, ctx$)
|
|
213
|
+
const source = resolveSource(ref, kind, ctx$)
|
|
217
214
|
|
|
218
215
|
// Asked of the RESOLVER rather than by re-reading the ref. `~` separates an
|
|
219
216
|
// alias only in the last segment, and a check that looked for one anywhere
|
|
@@ -334,24 +331,6 @@ function kindModel(props: {
|
|
|
334
331
|
// ONE definition, used by the add actions and by doctor. This reconstruction
|
|
335
332
|
// was written twice and the two copies had already diverged on exactly this
|
|
336
333
|
// point, which is the drift the kind spine exists to end.
|
|
337
|
-
function recordedRef(declared: any, name: string): string | undefined {
|
|
338
|
-
if (null == declared?.base || '' === declared.base) {
|
|
339
|
-
return undefined
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const origname = declared.origname || name
|
|
343
|
-
|
|
344
|
-
return Path.join(declared.base, '..', origname) +
|
|
345
|
-
(origname === name ? '' : '~' + name)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// A bare NAME, as opposed to a ref that locates a source.
|
|
350
|
-
function isBare(ref: string): boolean {
|
|
351
|
-
return !ref.includes('/') && !ref.includes(Path.sep)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
334
|
function capitalise(s: string): string {
|
|
356
335
|
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
357
336
|
}
|