@voxgig/sdkgen 3.5.1 → 3.6.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/bin/voxgig-sdkgen +1 -1
- package/dist/cmp/ExternalTarget.js +1 -1
- package/dist/cmp/ExternalTarget.js.map +1 -1
- package/dist/helpers/naming.d.ts +4 -1
- package/dist/helpers/naming.js +119 -5
- package/dist/helpers/naming.js.map +1 -1
- package/dist/helpers/opShape.js +72 -0
- package/dist/helpers/opShape.js.map +1 -1
- package/dist/helpers/packageMeta.d.ts +2 -1
- package/dist/helpers/packageMeta.js +57 -20
- package/dist/helpers/packageMeta.js.map +1 -1
- package/dist/sdkgen.d.ts +3 -3
- package/dist/sdkgen.js +61 -18
- package/dist/sdkgen.js.map +1 -1
- package/dist/testkit.d.ts +39 -0
- package/dist/testkit.js +663 -0
- package/dist/testkit.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +32 -8
- package/project/.sdk/src/cmp/c/Test_c.ts +5 -7
- package/project/.sdk/src/cmp/clojure/Test_clojure.ts +3 -7
- package/project/.sdk/src/cmp/cpp/Test_cpp.ts +4 -7
- package/project/.sdk/src/cmp/csharp/Package_csharp.ts +1 -1
- package/project/.sdk/src/cmp/csharp/Test_csharp.ts +5 -7
- package/project/.sdk/src/cmp/dart/Test_dart.ts +3 -4
- package/project/.sdk/src/cmp/elixir/Package_elixir.ts +1 -1
- package/project/.sdk/src/cmp/elixir/Test_elixir.ts +5 -6
- package/project/.sdk/src/cmp/go/EntityTypes_go.ts +1 -1
- package/project/.sdk/src/cmp/go/Entity_go.ts +1 -1
- package/project/.sdk/src/cmp/go/Gitignore_go.ts +6 -0
- package/project/.sdk/src/cmp/go/Main_go.ts +2 -2
- package/project/.sdk/src/cmp/go/Package_go.ts +1 -1
- package/project/.sdk/src/cmp/go/ReadmeExamplesTest_go.ts +59 -6
- package/project/.sdk/src/cmp/go/ReadmeExplanation_go.ts +1 -1
- package/project/.sdk/src/cmp/go/ReadmeHowto_go.ts +1 -1
- package/project/.sdk/src/cmp/go/ReadmeInstall_go.ts +1 -1
- package/project/.sdk/src/cmp/go/ReadmeModel_go.ts +1 -1
- package/project/.sdk/src/cmp/go/ReadmeQuick_go.ts +1 -1
- package/project/.sdk/src/cmp/go/ReadmeTopQuick_go.ts +1 -1
- package/project/.sdk/src/cmp/go/ReadmeTopTest_go.ts +1 -1
- package/project/.sdk/src/cmp/go/Test_go.ts +6 -8
- package/project/.sdk/src/cmp/go-cli/Main_go-cli.ts +6 -2
- package/project/.sdk/src/cmp/go-mcp/Main_go-mcp.ts +6 -2
- package/project/.sdk/src/cmp/java/Test_java.ts +4 -7
- package/project/.sdk/src/cmp/js/Package_js.ts +1 -1
- package/project/.sdk/src/cmp/js/Test_js.ts +4 -7
- package/project/.sdk/src/cmp/kotlin/Test_kotlin.ts +4 -7
- package/project/.sdk/src/cmp/lean/Test_lean.ts +3 -3
- package/project/.sdk/src/cmp/lua/Package_lua.ts +2 -2
- package/project/.sdk/src/cmp/lua/Test_lua.ts +5 -7
- package/project/.sdk/src/cmp/ocaml/Test_ocaml.ts +3 -7
- package/project/.sdk/src/cmp/perl/Package_perl.ts +1 -1
- package/project/.sdk/src/cmp/perl/Test_perl.ts +5 -7
- package/project/.sdk/src/cmp/php/EntityTypes_php.ts +13 -2
- package/project/.sdk/src/cmp/php/Package_php.ts +2 -2
- package/project/.sdk/src/cmp/php/Test_php.ts +5 -7
- package/project/.sdk/src/cmp/py/Package_py.ts +2 -2
- package/project/.sdk/src/cmp/py/Test_py.ts +5 -7
- package/project/.sdk/src/cmp/py-data/Main_py-data.ts +14 -19
- package/project/.sdk/src/cmp/rb/Package_rb.ts +3 -3
- package/project/.sdk/src/cmp/rb/ReadmeInstall_rb.ts +1 -1
- package/project/.sdk/src/cmp/rb/Test_rb.ts +5 -7
- package/project/.sdk/src/cmp/rust/Test_rust.ts +5 -7
- package/project/.sdk/src/cmp/scala/Test_scala.ts +5 -2
- package/project/.sdk/src/cmp/seneca-provider/Gitignore_seneca-provider.ts +161 -0
- package/project/.sdk/src/cmp/seneca-provider/Main_seneca-provider.ts +20 -11
- package/project/.sdk/src/cmp/swift/Test_swift.ts +5 -7
- package/project/.sdk/src/cmp/ts/Package_ts.ts +1 -1
- package/project/.sdk/src/cmp/ts/Test_ts.ts +8 -7
- package/project/.sdk/src/cmp/zig/Test_zig.ts +3 -7
- package/project/.sdk/tm/c/utility/make_point.c +64 -3
- package/project/.sdk/tm/clojure/src/sdk/core.clj +49 -14
- package/project/.sdk/tm/cpp/utility/pipeline.hpp +55 -3
- package/project/.sdk/tm/csharp/feature/TestFeature.cs +36 -2
- package/project/.sdk/tm/csharp/utility/MakePoint.cs +65 -2
- package/project/.sdk/tm/dart/lib/feature/test/TestFeature.dart +34 -4
- package/project/.sdk/tm/dart/lib/utility/MakePointUtility.dart +48 -2
- package/project/.sdk/tm/elixir/lib/projectname/utility.ex +70 -10
- package/project/.sdk/tm/go/feature/test_feature.go +34 -2
- package/project/.sdk/tm/go/utility/make_point.go +66 -2
- package/project/.sdk/tm/java/feature/TestFeature.java +42 -2
- package/project/.sdk/tm/java/utility/MakePoint.java +61 -2
- package/project/.sdk/tm/js/src/feature/test/TestFeature.js +22 -3
- package/project/.sdk/tm/js/src/utility/MakePointUtility.js +72 -2
- package/project/.sdk/tm/kotlin/feature/TestFeature.kt +31 -2
- package/project/.sdk/tm/kotlin/utility/MakePoint.kt +58 -2
- package/project/.sdk/tm/lean/src/SdkUtility.lean +28 -0
- package/project/.sdk/tm/lua/utility/make_point.lua +65 -2
- package/project/.sdk/tm/ocaml/sdk_runtime.ml +40 -2
- package/project/.sdk/tm/perl/utility/make_point.pm +55 -2
- package/project/.sdk/tm/php/feature/TestFeature.php +30 -1
- package/project/.sdk/tm/php/utility/MakePoint.php +49 -1
- package/project/.sdk/tm/py/pkg/utility/make_point.py +56 -2
- package/project/.sdk/tm/rb/utility/make_point.rb +42 -2
- package/project/.sdk/tm/rust/utility/make_point.rs +69 -2
- package/project/.sdk/tm/scala/feature/TestFeature.scala +36 -1
- package/project/.sdk/tm/scala/utility/Make.scala +57 -3
- package/project/.sdk/tm/swift/Sources/ProjectNameSDK/feature/TestFeature.swift +29 -1
- package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Make.swift +51 -2
- package/project/.sdk/tm/ts/src/feature/test/TestFeature.ts +22 -3
- package/project/.sdk/tm/ts/src/utility/MakePointUtility.ts +72 -2
- package/project/.sdk/tm/zig/core/utility.zig +66 -3
- package/project/sdkgen-package.json +1 -2
- package/src/cmp/ExternalTarget.ts +1 -1
- package/src/helpers/naming.ts +129 -5
- package/src/helpers/opShape.ts +85 -0
- package/src/helpers/packageMeta.ts +59 -20
- package/src/sdkgen.ts +68 -20
- package/src/testkit.ts +863 -0
- package/testkit.d.ts +3 -0
- package/testkit.js +12 -0
- package/project/.sdk/model/target/haskell.aontu +0 -72
- package/project/.sdk/src/cmp/haskell/Config_haskell.ts +0 -98
- package/project/.sdk/src/cmp/haskell/Entity_haskell.ts +0 -13
- package/project/.sdk/src/cmp/haskell/Gitignore_haskell.ts +0 -27
- package/project/.sdk/src/cmp/haskell/Main_haskell.ts +0 -123
- package/project/.sdk/src/cmp/haskell/Package_haskell.ts +0 -60
- package/project/.sdk/src/cmp/haskell/ReadmeEntity_haskell.ts +0 -186
- package/project/.sdk/src/cmp/haskell/ReadmeExamplesTest_haskell.ts +0 -135
- package/project/.sdk/src/cmp/haskell/ReadmeExplanation_haskell.ts +0 -48
- package/project/.sdk/src/cmp/haskell/ReadmeHowto_haskell.ts +0 -168
- package/project/.sdk/src/cmp/haskell/ReadmeInstall_haskell.ts +0 -51
- package/project/.sdk/src/cmp/haskell/ReadmeIntro_haskell.ts +0 -67
- package/project/.sdk/src/cmp/haskell/ReadmeModel_haskell.ts +0 -162
- package/project/.sdk/src/cmp/haskell/ReadmeOptions_haskell.ts +0 -74
- package/project/.sdk/src/cmp/haskell/ReadmeQuick_haskell.ts +0 -241
- package/project/.sdk/src/cmp/haskell/ReadmeRef_haskell.ts +0 -408
- package/project/.sdk/src/cmp/haskell/ReadmeTopHowto_haskell.ts +0 -31
- package/project/.sdk/src/cmp/haskell/ReadmeTopQuick_haskell.ts +0 -104
- package/project/.sdk/src/cmp/haskell/ReadmeTopTest_haskell.ts +0 -80
- package/project/.sdk/src/cmp/haskell/Test_haskell.ts +0 -300
- package/project/.sdk/src/cmp/haskell/tsconfig.json +0 -15
- package/project/.sdk/src/cmp/haskell/utility_haskell.ts +0 -166
- package/project/.sdk/tm/haskell/LICENSE +0 -21
- package/project/.sdk/tm/haskell/Makefile +0 -22
- package/project/.sdk/tm/haskell/VERSION +0 -1
- package/project/.sdk/tm/haskell/src/SdkFeatures.hs +0 -1440
- package/project/.sdk/tm/haskell/src/SdkHelpers.hs +0 -337
- package/project/.sdk/tm/haskell/src/SdkJson.hs +0 -111
- package/project/.sdk/tm/haskell/src/SdkRuntime.hs +0 -1219
- package/project/.sdk/tm/haskell/src/SdkTypes.hs +0 -195
- package/project/.sdk/tm/haskell/src/VoxgigStruct.hs +0 -2299
- package/project/.sdk/tm/haskell/src/Vregex.hs +0 -237
- package/project/.sdk/tm/haskell/src/feature/README.md +0 -5
- package/project/.sdk/tm/haskell/src/feature/audit/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/base/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/cache/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/clienttrack/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/debug/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/idempotency/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/log/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/metrics/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/netsim/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/paging/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/proxy/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/ratelimit/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/rbac/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/retry/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/streaming/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/telemetry/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/test/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/src/feature/timeout/.gitkeep +0 -0
- package/project/.sdk/tm/haskell/test/Harness.hs +0 -281
- package/project/.sdk/tm/haskell/test/Runner.hs +0 -47
- package/project/.sdk/tm/haskell/test/StructCorpus.hs +0 -449
- package/project/.sdk/tm/haskell/test/TCustomUtility.hs +0 -38
- package/project/.sdk/tm/haskell/test/TFeature.hs +0 -794
- package/project/.sdk/tm/haskell/test/TNetsim.hs +0 -50
- package/project/.sdk/tm/haskell/test/TPipeline.hs +0 -334
- package/project/.sdk/tm/haskell/test/TPrimaryUtility.hs +0 -386
- package/project/.sdk/tm/haskell/test/Testutil.hs +0 -59
package/src/testkit.ts
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
/* Copyright (c) 2024-2026 Voxgig Ltd, MIT License */
|
|
2
|
+
|
|
3
|
+
// THE TEST KIT: `@voxgig/sdkgen/testkit`.
|
|
4
|
+
//
|
|
5
|
+
// See §14 of docs/design/sdkgen-packages.md.
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS EXISTS
|
|
8
|
+
//
|
|
9
|
+
// sdkgen's own suites are CLOSED. `parity.test.ts` and `featuremodel.test.ts`
|
|
10
|
+
// derive their sets from `ts/project/.sdk` listings; `generate.test.ts` runs
|
|
11
|
+
// the per-language components out of a staged copy of that same tree. All of
|
|
12
|
+
// it is excellent coverage that an external package gets exactly none of —
|
|
13
|
+
// and an external package is where the coverage is needed most, because its
|
|
14
|
+
// content reaches a consumer through the same add pipeline with none of the
|
|
15
|
+
// same review.
|
|
16
|
+
//
|
|
17
|
+
// So the machinery is parameterised rather than reimplemented: this module is
|
|
18
|
+
// the staging in `build/scaffold-stage.js` and the generation harness in
|
|
19
|
+
// `ts/test/generateharness.ts`, with the paths taken as arguments instead of
|
|
20
|
+
// hardcoded to the bundled scaffold.
|
|
21
|
+
//
|
|
22
|
+
// WHAT A PACKAGE AUTHOR DOES WITH IT
|
|
23
|
+
//
|
|
24
|
+
// const consumer = stageConsumer()
|
|
25
|
+
// await consumer.addPackage(__dirname + '/..') // the package under test
|
|
26
|
+
// consumer.compile() // as a consumer's build does
|
|
27
|
+
// const { files, leaks } = await generateInto(consumer, { model })
|
|
28
|
+
//
|
|
29
|
+
// That runs the REAL add pipeline and the REAL generation, so provenance,
|
|
30
|
+
// index handling, the feature fan-out and the trim catalogue are all exercised
|
|
31
|
+
// against the package as published rather than as described.
|
|
32
|
+
//
|
|
33
|
+
// NO RUNTIME DEPENDENCIES. This package has none and the test kit does not
|
|
34
|
+
// introduce any: `compile()` reaches for a transpiler at call time and says
|
|
35
|
+
// which ones it looked for if it finds none.
|
|
36
|
+
|
|
37
|
+
import Fs from 'node:fs'
|
|
38
|
+
import Os from 'node:os'
|
|
39
|
+
import Path from 'node:path'
|
|
40
|
+
|
|
41
|
+
import { Jostraca } from 'jostraca'
|
|
42
|
+
|
|
43
|
+
import { SdkGen } from './sdkgen'
|
|
44
|
+
|
|
45
|
+
import { package_add } from './action/package'
|
|
46
|
+
import { ACTION_MAP } from './action/dispatch'
|
|
47
|
+
|
|
48
|
+
import { KIT } from './types'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
// This package's own root — `<...>/node_modules/@voxgig/sdkgen` for a
|
|
52
|
+
// consumer, or the checkout when sdkgen tests itself. Computed from this
|
|
53
|
+
// module's location rather than by `require.resolve`, which would go through
|
|
54
|
+
// the `exports` map and answer with `dist/sdkgen.js` instead of the root.
|
|
55
|
+
const SDKGEN_ROOT = Path.resolve(__dirname, '..')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// The placeholder tokens template substitution is supposed to replace. One
|
|
59
|
+
// surviving into generated output means a replace map did not reach a file —
|
|
60
|
+
// the failure mode `generate.test.ts` scans the bundled targets for, made
|
|
61
|
+
// available to packages that ship template trees of their own.
|
|
62
|
+
//
|
|
63
|
+
// KEPT IN STEP WITH `generate.test.ts` DELIBERATELY. `PROJECTENV` and
|
|
64
|
+
// `PROJECTVERSION` are added by `ensureStdrep` / `templateReplacements`, not
|
|
65
|
+
// by the name map, and a kit that scanned only for the name tokens would let a
|
|
66
|
+
// package ship an unsubstituted version string and still report `leaks: []`.
|
|
67
|
+
const PLACEHOLDERS = [
|
|
68
|
+
'ProjectName', 'PROJECTNAME', 'PROJECTENV', 'PROJECTVERSION', 'GOMODULE',
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
// A MODEL PATH between the delimiters — identifiers and dots — not any `$$`
|
|
73
|
+
// pair. A surviving `$$model.path$$` means a Fragment or Copy whose model
|
|
74
|
+
// interpolation never ran, which the token list above cannot see.
|
|
75
|
+
//
|
|
76
|
+
// Constrained on purpose, and this is the same pattern `generate.test.ts`
|
|
77
|
+
// settled on: in a Makefile `$$` is how you write a literal `$`, so a loose
|
|
78
|
+
// pattern reported py-data's `$${GITHUB_TOKEN:-$$(gh auth token)}` — a correct
|
|
79
|
+
// recipe — as a leak.
|
|
80
|
+
const PLACEHOLDER_REF = /\$\$[A-Za-z_][A-Za-z0-9_.]*\$\$/
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
type StageOptions = {
|
|
84
|
+
// Where to build the consumer. A fresh temp directory by default.
|
|
85
|
+
dir?: string
|
|
86
|
+
|
|
87
|
+
// The project's API name, which every derived name comes from.
|
|
88
|
+
name?: string
|
|
89
|
+
|
|
90
|
+
// The project's OWN model text, appended to `model/sdk.aontu`.
|
|
91
|
+
//
|
|
92
|
+
// THIS WRITES A FILE. It does NOT reach the action context — see
|
|
93
|
+
// `setModel` on the consumer, and use that if an add needs to SEE what is
|
|
94
|
+
// declared here. The two are separate on purpose rather than by oversight:
|
|
95
|
+
// nothing recompiles a model mid-process, so there is no honest way to make
|
|
96
|
+
// a string written here appear in an action's `actx.model` automatically.
|
|
97
|
+
extra?: string
|
|
98
|
+
|
|
99
|
+
// Record log lines instead of discarding them, for assertions about what an
|
|
100
|
+
// action reported. The array is exposed as `consumer.log.lines`.
|
|
101
|
+
recordLog?: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
type Consumer = {
|
|
106
|
+
// The project root — the directory that HOLDS `.sdk`. Generation is rooted
|
|
107
|
+
// here; an action's folder is `.sdk` itself. Conflating the two is the
|
|
108
|
+
// mistake this pair of fields exists to prevent.
|
|
109
|
+
root: string
|
|
110
|
+
sdk: string
|
|
111
|
+
|
|
112
|
+
actx: any
|
|
113
|
+
log: any
|
|
114
|
+
|
|
115
|
+
// Install a package by ref, through the real `package add`.
|
|
116
|
+
addPackage: (ref: string, flags?: any) => Promise<any>
|
|
117
|
+
|
|
118
|
+
// Install one item of one kind, through the real `<kind> add`.
|
|
119
|
+
add: (kind: string, ref: string, flags?: any) => Promise<any>
|
|
120
|
+
|
|
121
|
+
// A bundled target/feature ref, for a consumer that wants one alongside the
|
|
122
|
+
// package's own content — `wfeat`'s overlay for `ts` needs `ts` present.
|
|
123
|
+
bundledRef: (kind: string, name: string) => string
|
|
124
|
+
|
|
125
|
+
// Install a compiled model as the one the ADD ACTIONS see.
|
|
126
|
+
//
|
|
127
|
+
// Needed because an action reads `actx.model`, which is compiled from
|
|
128
|
+
// `model/sdk.aontu` BEFORE the run — nothing recompiles mid-process. The
|
|
129
|
+
// CLI does that compile per invocation; a kit staging several adds in one
|
|
130
|
+
// process does not, so a feature declared in the project's model is
|
|
131
|
+
// invisible to a later `target add` unless it is installed here. That
|
|
132
|
+
// matters: `target add` TRIMS feature source down to what the model
|
|
133
|
+
// selects, so a target added against an empty model ships none of it.
|
|
134
|
+
//
|
|
135
|
+
// `package add` handles its own within-run sequencing (it teaches the
|
|
136
|
+
// in-memory model about each kind's items as it installs them), so this is
|
|
137
|
+
// for adds the caller sequences itself.
|
|
138
|
+
setModel: (model: any) => void
|
|
139
|
+
|
|
140
|
+
// Run `fn` with the working directory set to `.sdk`, which is where a
|
|
141
|
+
// consumer runs generation from. Exposed because a caller doing its own
|
|
142
|
+
// generate() needs the same contract. A promise-returning `fn` is awaited
|
|
143
|
+
// before the directory is restored.
|
|
144
|
+
inSdk: (<T>(fn: () => Promise<T>) => Promise<T>) & (<T>(fn: () => T) => T)
|
|
145
|
+
|
|
146
|
+
// Compile `.sdk/src/cmp/**` to `.sdk/dist/cmp/**`, which is what a
|
|
147
|
+
// consumer's own `npm run build` does and what `requirePath` reads.
|
|
148
|
+
compile: (opts?: { transform?: (src: string, file: string) => string }) => number
|
|
149
|
+
|
|
150
|
+
// Paths written so far, relative to `.sdk`, sorted.
|
|
151
|
+
files: () => string[]
|
|
152
|
+
|
|
153
|
+
cleanup: () => void
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
const noop = () => { }
|
|
158
|
+
|
|
159
|
+
function makeLog(lines?: any[]): any {
|
|
160
|
+
const push = (level: string) => (entry: any) => {
|
|
161
|
+
if (lines) lines.push({ level, ...entry })
|
|
162
|
+
}
|
|
163
|
+
const log: any = {
|
|
164
|
+
lines,
|
|
165
|
+
info: push('info'), debug: push('debug'), warn: push('warn'),
|
|
166
|
+
error: push('error'), trace: push('trace'), fatal: push('fatal'),
|
|
167
|
+
}
|
|
168
|
+
log.child = () => log
|
|
169
|
+
return log
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
// A CONSUMER PROJECT ON REAL DISK, not in memfs.
|
|
174
|
+
//
|
|
175
|
+
// It has to be real: `requirePath` resolves a component with an actual Node
|
|
176
|
+
// `require` against `<root>/.sdk/dist/cmp/...`, and components read sibling
|
|
177
|
+
// fragment files off disk relative to their own `__dirname`. A memfs project
|
|
178
|
+
// can exercise the add pipeline (sdkgen's own action suites do) but can never
|
|
179
|
+
// RUN what it installed, which is the half a package author most needs.
|
|
180
|
+
function stageConsumer(opts: StageOptions = {}): Consumer {
|
|
181
|
+
// THE ROOT IS USED VERBATIM. Do not "canonicalise" it.
|
|
182
|
+
//
|
|
183
|
+
// This exact string is handed to `generate()` as its output folder, and
|
|
184
|
+
// `generateInto` strips it back off to key the result. Those two uses only
|
|
185
|
+
// agree while it is ONE string, so any transformation here would have to be
|
|
186
|
+
// one jostraca performs too — and `realpathSync` is not.
|
|
187
|
+
//
|
|
188
|
+
// It WAS resolved here briefly, added as hardening for a macOS case
|
|
189
|
+
// (`/var/folders` vs `/private/var/folders`) that was measured beforehand
|
|
190
|
+
// and does not arise: jostraca realpaths a copy's SOURCE, not the output
|
|
191
|
+
// folder. Hardening against a hazard that was not there is its own reason
|
|
192
|
+
// to revert.
|
|
193
|
+
//
|
|
194
|
+
// (It was also blamed, wrongly, for the Windows failure that followed. That
|
|
195
|
+
// was the drive letter — see `volkey` in generateInto — and the short name
|
|
196
|
+
// in `C:\Users\RUNNER~1\…` was a coincidence of the runner's paths, present
|
|
197
|
+
// on both sides of the comparison and never the difference.)
|
|
198
|
+
const root = opts.dir ?? Fs.mkdtempSync(Path.join(Os.tmpdir(), 'sdkgen-consumer-'))
|
|
199
|
+
const sdk = Path.join(root, '.sdk')
|
|
200
|
+
|
|
201
|
+
Fs.mkdirSync(Path.join(sdk, 'model', 'target'), { recursive: true })
|
|
202
|
+
Fs.mkdirSync(Path.join(sdk, 'model', 'feature'), { recursive: true })
|
|
203
|
+
|
|
204
|
+
Fs.writeFileSync(Path.join(sdk, 'model', 'target', 'target-index.aontu'),
|
|
205
|
+
'# Targets\n')
|
|
206
|
+
Fs.writeFileSync(Path.join(sdk, 'model', 'feature', 'feature-index.aontu'),
|
|
207
|
+
'# Features\n')
|
|
208
|
+
|
|
209
|
+
const name = opts.name ?? 'demo'
|
|
210
|
+
|
|
211
|
+
// The project's OWN model, written once by create-sdkgen at init. It
|
|
212
|
+
// includes the indexes of the kinds that existed THEN — which is why a
|
|
213
|
+
// consumer staged here can show what an existing project does when a new
|
|
214
|
+
// kind arrives, rather than assuming every index is already wired.
|
|
215
|
+
Fs.writeFileSync(Path.join(sdk, 'model', 'sdk.aontu'),
|
|
216
|
+
"name: '" + name + "'\n" +
|
|
217
|
+
'@"target/target-index.aontu"\n' +
|
|
218
|
+
'@"feature/feature-index.aontu"\n' +
|
|
219
|
+
(opts.extra ? opts.extra + '\n' : ''))
|
|
220
|
+
|
|
221
|
+
// `@voxgig/sdkgen` has to be resolvable FROM THE CONSUMER, because the
|
|
222
|
+
// feature fan-out reads the bundled feature models through the path a
|
|
223
|
+
// consumer sees them at (`node_modules/@voxgig/sdkgen/...`, relative to the
|
|
224
|
+
// project) and scaffold components `require('@voxgig/sdkgen')` by name.
|
|
225
|
+
//
|
|
226
|
+
// 'junction' is the portable spelling: on Windows it creates a directory
|
|
227
|
+
// junction, which needs no elevation, and on POSIX the type argument is
|
|
228
|
+
// ignored and an ordinary symlink results. A copy would work too and costs
|
|
229
|
+
// the whole 27-target template tree per staged consumer.
|
|
230
|
+
// NODE_MODULES LIVES INSIDE `.sdk`, NOT BESIDE IT.
|
|
231
|
+
//
|
|
232
|
+
// A generated SDK's `.sdk` is itself an npm package root — it has its own
|
|
233
|
+
// package.json and its own install — so that is where a consumer's
|
|
234
|
+
// `@voxgig/sdkgen` actually sits. It matters because the feature fan-out
|
|
235
|
+
// composes the path from the ACTION FOLDER (`<.sdk>/node_modules/...`)
|
|
236
|
+
// rather than resolving it upward; put the link one level too high and
|
|
237
|
+
// every bundled feature reports `feature-source-unresolved` while
|
|
238
|
+
// `require('@voxgig/sdkgen')` from a component keeps working, because
|
|
239
|
+
// Node's upward search finds either. One of the two readers is forgiving
|
|
240
|
+
// and the other is not.
|
|
241
|
+
const modules = Path.join(sdk, 'node_modules')
|
|
242
|
+
const links = [linkModule(modules, '@voxgig/sdkgen', SDKGEN_ROOT)]
|
|
243
|
+
|
|
244
|
+
// ...and sdkgen's PEERS, because the base model schema a consumer compiles
|
|
245
|
+
// against pulls in `@voxgig/apidef/model/apidef.aontu` by package name. A
|
|
246
|
+
// consumer that really installed sdkgen has these; a staged one has to be
|
|
247
|
+
// given them, or every model compile here fails on an include that resolves
|
|
248
|
+
// fine everywhere else.
|
|
249
|
+
for (const dep of peerNames()) {
|
|
250
|
+
const from = peerRoot(dep)
|
|
251
|
+
if (null != from) {
|
|
252
|
+
links.push(linkModule(modules, dep, from))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const lines: any[] = []
|
|
257
|
+
const log = makeLog(opts.recordLog ? lines : undefined)
|
|
258
|
+
|
|
259
|
+
const actx: any = {
|
|
260
|
+
fs: () => Fs,
|
|
261
|
+
log,
|
|
262
|
+
folder: sdk,
|
|
263
|
+
model: {
|
|
264
|
+
const: { name, Name: name.charAt(0).toUpperCase() + name.slice(1) },
|
|
265
|
+
main: {
|
|
266
|
+
[KIT]: { feature: {}, entity: {}, target: {} },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
url: Path.join(sdk, 'model', 'sdk.aontu'),
|
|
270
|
+
jostraca: Jostraca({ existing: { txt: { write: true, merge: false } } }),
|
|
271
|
+
opts: { dryrun: false },
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const files = () => walk(sdk)
|
|
275
|
+
.map((p: string) => Path.relative(sdk, p).split(Path.sep).join('/'))
|
|
276
|
+
.filter((p: string) => !p.startsWith('.jostraca/') && !p.includes('/.jostraca/'))
|
|
277
|
+
.sort()
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
root, sdk, actx, log,
|
|
281
|
+
|
|
282
|
+
addPackage: async (ref: string, flags: any = {}) => {
|
|
283
|
+
actx.flags = flags
|
|
284
|
+
return package_add([ref], actx)
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
// Through `ACTION_MAP`, which is the SAME dispatch the CLI uses — so a
|
|
288
|
+
// kind registered later is installable here with no change to the kit,
|
|
289
|
+
// and a kind whose action is missing fails the way the CLI fails.
|
|
290
|
+
add: async (kind: string, ref: string, flags: any = {}) => {
|
|
291
|
+
const action = (ACTION_MAP as any)[kind]
|
|
292
|
+
if (null == action) {
|
|
293
|
+
throw new Error('testkit: no such kind: ' + kind +
|
|
294
|
+
' (known: ' + Object.keys(ACTION_MAP).sort().join(', ') + ')')
|
|
295
|
+
}
|
|
296
|
+
actx.flags = flags
|
|
297
|
+
// `[kind, cmd, ...refs]` — the CLI's own argv shape, which is what
|
|
298
|
+
// `action_<kind>` parses (it reads the verb at args[1]).
|
|
299
|
+
return action([kind, 'add', ref], actx)
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
// CONSUMER-RELATIVE, not absolute, and that is not a stylistic choice.
|
|
303
|
+
//
|
|
304
|
+
// `target add` records `base` as the resolved source folder, so an
|
|
305
|
+
// absolute ref writes THIS MACHINE'S path into the installed model. Two
|
|
306
|
+
// things then go wrong at once: the copy is not reproducible across
|
|
307
|
+
// machines, and the feature fan-out compares the target's own tm folder
|
|
308
|
+
// against the one it reaches through `node_modules` — textually different
|
|
309
|
+
// paths for the same tree, which it reports as a shadowing overlay. Both
|
|
310
|
+
// disappear when the ref is spelled the way a consumer spells it.
|
|
311
|
+
bundledRef: (kind: string, name: string) =>
|
|
312
|
+
'target' === kind ? 'node_modules/@voxgig/sdkgen/project/' + name : name,
|
|
313
|
+
|
|
314
|
+
setModel: (model: any) => { actx.model = model },
|
|
315
|
+
|
|
316
|
+
// AWAITS A PROMISE-RETURNING CALLBACK before restoring the directory.
|
|
317
|
+
//
|
|
318
|
+
// A synchronous `finally` would put the cwd back the moment `fn` RETURNS,
|
|
319
|
+
// which for an async callback is its first `await` — so the generation it
|
|
320
|
+
// was wrapping would do most of its work, including every CWD-relative
|
|
321
|
+
// template copy and the later docs and out-of-tree passes, from the
|
|
322
|
+
// caller's directory. The wrapper would look correct and protect almost
|
|
323
|
+
// nothing.
|
|
324
|
+
inSdk: (fn: any): any => {
|
|
325
|
+
const prev = process.cwd()
|
|
326
|
+
process.chdir(sdk)
|
|
327
|
+
|
|
328
|
+
let out: any
|
|
329
|
+
try {
|
|
330
|
+
out = fn()
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
process.chdir(prev)
|
|
334
|
+
throw err
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (null != out && 'function' === typeof out.then) {
|
|
338
|
+
return out.then(
|
|
339
|
+
(v: any) => { process.chdir(prev); return v },
|
|
340
|
+
(err: any) => { process.chdir(prev); throw err })
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
process.chdir(prev)
|
|
344
|
+
return out
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
compile: (copts = {}) => compileComponents(sdk, copts.transform),
|
|
348
|
+
|
|
349
|
+
files,
|
|
350
|
+
|
|
351
|
+
cleanup: () => {
|
|
352
|
+
if (null == opts.dir) {
|
|
353
|
+
// The links go FIRST, explicitly. `rmSync` would remove them without
|
|
354
|
+
// following (measured), so this is belt and braces for the walk — but
|
|
355
|
+
// it is NOT redundant on Windows, where a junction refuses `unlink`
|
|
356
|
+
// and `force: true` forgives only ENOENT, so leaving it to the walk
|
|
357
|
+
// can throw.
|
|
358
|
+
for (const link of links) {
|
|
359
|
+
unlink(link)
|
|
360
|
+
}
|
|
361
|
+
Fs.rmSync(root, { recursive: true, force: true })
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
// REMOVE A LINK, WHICHEVER KIND IT TURNED OUT TO BE.
|
|
369
|
+
//
|
|
370
|
+
// `linkModule` makes a symlink on POSIX and a directory JUNCTION on Windows,
|
|
371
|
+
// and falls back to a real directory holding a re-export shim. `unlinkSync`
|
|
372
|
+
// removes the first, fails with EPERM on the second, and cannot remove the
|
|
373
|
+
// third.
|
|
374
|
+
//
|
|
375
|
+
// WHAT THIS IS AND IS NOT FOR. Node's recursive `rmSync` does NOT follow a
|
|
376
|
+
// symlink or a junction — it removes the link itself — so the tree walk that
|
|
377
|
+
// follows is not going to reach through into the sdkgen checkout. That was
|
|
378
|
+
// measured, not assumed. What this function buys is narrower and still worth
|
|
379
|
+
// having: cleanup that does not throw on Windows (a junction refuses
|
|
380
|
+
// `unlink`, and `force: true` forgives only ENOENT), and not depending on
|
|
381
|
+
// that `rmSync` behaviour holding forever for an operation whose blast radius
|
|
382
|
+
// would be a developer's checkout.
|
|
383
|
+
function unlink(link: string): void {
|
|
384
|
+
let stat: any
|
|
385
|
+
try {
|
|
386
|
+
stat = Fs.lstatSync(link)
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A LINK OF SOME KIND — never recurse. `lstat` reports a Windows junction as
|
|
393
|
+
// a symbolic link, which is exactly the case a recursive remove must not
|
|
394
|
+
// reach: it would delete the sdkgen checkout on the other side.
|
|
395
|
+
//
|
|
396
|
+
// `unlink` removes a POSIX symlink and refuses a junction (EPERM); `rmdir`
|
|
397
|
+
// removes a junction and refuses a symlink. Try both rather than branch on
|
|
398
|
+
// the platform.
|
|
399
|
+
if (stat.isSymbolicLink()) {
|
|
400
|
+
try { Fs.unlinkSync(link); return } catch (err) { /* junction */ }
|
|
401
|
+
try { Fs.rmdirSync(link) } catch (err) { /* already gone */ }
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Not a link: the re-export shim `linkModule` writes when symlinking is
|
|
406
|
+
// refused. A real directory we created, so removing its contents is safe.
|
|
407
|
+
try {
|
|
408
|
+
Fs.rmSync(link, { recursive: true, force: true })
|
|
409
|
+
}
|
|
410
|
+
catch (err) { /* already gone */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
// A path as an in-memory VOLUME spells it: forward slashes, no drive letter.
|
|
415
|
+
//
|
|
416
|
+
// memfs stores `/a/b`, never `C:/a/b`, so a Windows path has to lose both its
|
|
417
|
+
// separators and its drive before it can be compared with a volume key. This
|
|
418
|
+
// has now caused two Windows failures — the second one silently, because
|
|
419
|
+
// `Path.relative` between a drive-less key and a real root does not fail, it
|
|
420
|
+
// resolves the key against the CWD and returns a confidently wrong answer.
|
|
421
|
+
//
|
|
422
|
+
// Backslashes are replaced unconditionally rather than via `Path.sep`, so the
|
|
423
|
+
// rule is the same function on every platform and can be tested anywhere. A
|
|
424
|
+
// Windows filename cannot contain a backslash, so nothing legitimate is lost.
|
|
425
|
+
function volumeKey(p: string): string {
|
|
426
|
+
return p.replace(/\\/g, '/').replace(/^[A-Za-z]:/, '')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
// The peer packages a consumer necessarily has installed alongside sdkgen.
|
|
431
|
+
// Read from the manifest rather than listed here, so a peer added later is
|
|
432
|
+
// linked without anyone remembering to.
|
|
433
|
+
function peerNames(): string[] {
|
|
434
|
+
try {
|
|
435
|
+
const pkg = JSON.parse(
|
|
436
|
+
Fs.readFileSync(Path.join(SDKGEN_ROOT, 'package.json'), 'utf8'))
|
|
437
|
+
return Object.keys(pkg.peerDependencies ?? {})
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
return []
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
// WHERE A PEER ACTUALLY LIVES — asked of Node, not guessed from a path.
|
|
446
|
+
//
|
|
447
|
+
// The tempting version is `<SDKGEN_ROOT>/node_modules/<dep>`, and it is right
|
|
448
|
+
// only in this checkout. npm HOISTS: in a real installation sdkgen's peers are
|
|
449
|
+
// siblings of `@voxgig/sdkgen` under the host project's `node_modules`, not
|
|
450
|
+
// children of it. So the guessed path exists here, misses everywhere else, and
|
|
451
|
+
// the failure is silent — the peer is skipped, the staged consumer has no
|
|
452
|
+
// `@voxgig/apidef`, and the model compile fails on an include that resolves
|
|
453
|
+
// fine in every other context.
|
|
454
|
+
//
|
|
455
|
+
// `require.resolve` with `paths` walks the real chain, hoisted or not. The
|
|
456
|
+
// package.json is resolved rather than the entry point because a peer may not
|
|
457
|
+
// export one, and its directory is what has to be linked.
|
|
458
|
+
function peerRoot(dep: string): string | undefined {
|
|
459
|
+
try {
|
|
460
|
+
return Path.dirname(
|
|
461
|
+
require.resolve(dep + '/package.json', { paths: [SDKGEN_ROOT] }))
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
// Some packages restrict `exports` and refuse the package.json subpath.
|
|
465
|
+
// Fall back to the entry point and climb to the directory that holds one.
|
|
466
|
+
try {
|
|
467
|
+
let dir = Path.dirname(require.resolve(dep, { paths: [SDKGEN_ROOT] }))
|
|
468
|
+
for (let up = 0; up < 8; up++) {
|
|
469
|
+
if (Fs.existsSync(Path.join(dir, 'package.json'))) return dir
|
|
470
|
+
const parent = Path.dirname(dir)
|
|
471
|
+
if (parent === dir) break
|
|
472
|
+
dir = parent
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch (err2) { /* genuinely not installed */ }
|
|
476
|
+
|
|
477
|
+
return undefined
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
// One `node_modules/<name>` entry pointing at an existing package directory.
|
|
483
|
+
//
|
|
484
|
+
// 'junction' is the portable spelling: on Windows it creates a directory
|
|
485
|
+
// junction, which needs no elevation, and on POSIX the type argument is
|
|
486
|
+
// ignored and an ordinary symlink results. A copy would work too and costs the
|
|
487
|
+
// whole 27-target template tree per staged consumer.
|
|
488
|
+
function linkModule(modules: string, name: string, from: string): string {
|
|
489
|
+
const link = Path.join(modules, ...name.split('/'))
|
|
490
|
+
Fs.mkdirSync(Path.dirname(link), { recursive: true })
|
|
491
|
+
|
|
492
|
+
if (Fs.existsSync(link)) return link
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
Fs.symlinkSync(from, link, 'junction')
|
|
496
|
+
}
|
|
497
|
+
catch (err: any) {
|
|
498
|
+
// Last resort: a re-export shim. It satisfies `require('<name>')` but NOT
|
|
499
|
+
// deep file reads under the package, so a model include would still fail
|
|
500
|
+
// — which is why this is the fallback and not the mechanism.
|
|
501
|
+
Fs.mkdirSync(link, { recursive: true })
|
|
502
|
+
Fs.writeFileSync(Path.join(link, 'package.json'),
|
|
503
|
+
JSON.stringify({ name, version: '0.0.0', main: 'index.js' }) + '\n')
|
|
504
|
+
Fs.writeFileSync(Path.join(link, 'index.js'),
|
|
505
|
+
'module.exports = require(' + JSON.stringify(from) + ')\n')
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return link
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
function walk(dir: string): string[] {
|
|
513
|
+
if (!Fs.existsSync(dir)) return []
|
|
514
|
+
const out: string[] = []
|
|
515
|
+
for (const entry of Fs.readdirSync(dir, { withFileTypes: true })) {
|
|
516
|
+
const full = Path.join(dir, entry.name)
|
|
517
|
+
if (entry.isDirectory()) out.push(...walk(full))
|
|
518
|
+
else out.push(full)
|
|
519
|
+
}
|
|
520
|
+
return out
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
// TRANSPILE, DO NOT TYPE-CHECK.
|
|
525
|
+
//
|
|
526
|
+
// Type-checking a package's components is a BUILD-time gate (the package runs
|
|
527
|
+
// `tsc --noEmit` over its own `src/cmp/**`, the way this repo's
|
|
528
|
+
// `check-scaffold` does). Doing it again per staged consumer would add seconds
|
|
529
|
+
// to every test for an answer the build already has. What the kit needs here
|
|
530
|
+
// is only executable JS at the path `requirePath` reads.
|
|
531
|
+
//
|
|
532
|
+
// Neither transpiler is a dependency of this package, which has none. They are
|
|
533
|
+
// looked up at call time, and if neither is present the error names both
|
|
534
|
+
// rather than failing later as a missing module inside `requirePath`.
|
|
535
|
+
function compileComponents(
|
|
536
|
+
sdk: string,
|
|
537
|
+
transform?: (src: string, file: string) => string,
|
|
538
|
+
): number {
|
|
539
|
+
const srcdir = Path.join(sdk, 'src', 'cmp')
|
|
540
|
+
const outdir = Path.join(sdk, 'dist', 'cmp')
|
|
541
|
+
|
|
542
|
+
if (!Fs.existsSync(srcdir)) return 0
|
|
543
|
+
|
|
544
|
+
const xform = transform ?? defaultTransform()
|
|
545
|
+
|
|
546
|
+
let count = 0
|
|
547
|
+
for (const file of walk(srcdir)) {
|
|
548
|
+
const rel = Path.relative(srcdir, file)
|
|
549
|
+
|
|
550
|
+
// Fragments are template source carrying placeholder tokens, not valid
|
|
551
|
+
// standalone modules — the same exclusion `tsconfig.scaffold.json` makes.
|
|
552
|
+
if (rel.split(Path.sep).includes('fragment')) continue
|
|
553
|
+
|
|
554
|
+
const out = Path.join(outdir, rel.replace(/\.ts$/, '.js'))
|
|
555
|
+
Fs.mkdirSync(Path.dirname(out), { recursive: true })
|
|
556
|
+
|
|
557
|
+
if (!file.endsWith('.ts')) {
|
|
558
|
+
// Components read sibling non-TS files (fragments, docs) relative to
|
|
559
|
+
// their own __dirname, so those have to arrive in dist too.
|
|
560
|
+
Fs.copyFileSync(file, Path.join(outdir, rel))
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
Fs.writeFileSync(out, xform(Fs.readFileSync(file, 'utf8'), file))
|
|
565
|
+
count++
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return count
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
function defaultTransform(): (src: string, file: string) => string {
|
|
573
|
+
const tried: string[] = []
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
tried.push('sucrase')
|
|
577
|
+
const sucrase = require('sucrase')
|
|
578
|
+
return (src: string, file: string) => sucrase.transform(src, {
|
|
579
|
+
transforms: ['typescript', 'imports'],
|
|
580
|
+
filePath: file,
|
|
581
|
+
}).code
|
|
582
|
+
}
|
|
583
|
+
catch (err) { /* fall through to typescript */ }
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
tried.push('typescript')
|
|
587
|
+
const ts = require('typescript')
|
|
588
|
+
return (src: string, file: string) => ts.transpileModule(src, {
|
|
589
|
+
fileName: file,
|
|
590
|
+
compilerOptions: {
|
|
591
|
+
target: ts.ScriptTarget.ES2021,
|
|
592
|
+
module: ts.ModuleKind.CommonJS,
|
|
593
|
+
esModuleInterop: true,
|
|
594
|
+
},
|
|
595
|
+
}).outputText
|
|
596
|
+
}
|
|
597
|
+
catch (err) { /* fall through to the error */ }
|
|
598
|
+
|
|
599
|
+
throw new Error(
|
|
600
|
+
'testkit: no TypeScript transpiler found (looked for: ' +
|
|
601
|
+
tried.join(', ') + '). Add one as a devDependency, or pass ' +
|
|
602
|
+
'`compile({ transform })` with your own.')
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
type GenerateOptions = {
|
|
607
|
+
// The compiled model. A caller with aontu source unifies it themselves —
|
|
608
|
+
// the kit does not choose a model-compilation strategy for a package.
|
|
609
|
+
model: any
|
|
610
|
+
|
|
611
|
+
// The Root component. Defaults to one that renders every active target the
|
|
612
|
+
// way a create-sdkgen consumer's Root does.
|
|
613
|
+
root?: any
|
|
614
|
+
|
|
615
|
+
// Placeholder mentions that are NOT leaks, exactly as `parity.test.ts`
|
|
616
|
+
// keeps its list: a stated policy, not a mute button.
|
|
617
|
+
allowPlaceholder?: (path: string, token: string) => boolean
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
type GenerateResult = {
|
|
622
|
+
// Every generated path, relative to the consumer root, mapped to content.
|
|
623
|
+
files: Record<string, string>
|
|
624
|
+
|
|
625
|
+
// `<path>: <token>` for each surviving placeholder.
|
|
626
|
+
leaks: string[]
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
// GENERATE INTO MEMORY, from a consumer staged on disk.
|
|
631
|
+
//
|
|
632
|
+
// The split matters: the project (components, templates, model) is real,
|
|
633
|
+
// because that is what generation READS; the output is a memfs volume,
|
|
634
|
+
// because a test wants to assert on it rather than clean it up.
|
|
635
|
+
async function generateInto(
|
|
636
|
+
consumer: Consumer, opts: GenerateOptions,
|
|
637
|
+
): Promise<GenerateResult> {
|
|
638
|
+
// memfs is a devDependency of whoever is testing, not of this package.
|
|
639
|
+
let memfs: any
|
|
640
|
+
try {
|
|
641
|
+
memfs = require('memfs').memfs
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
throw new Error('testkit: generateInto needs `memfs` — add it as a devDependency')
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const { fs, vol } = memfs({})
|
|
648
|
+
|
|
649
|
+
const sdkgen = SdkGen({
|
|
650
|
+
fs: layeredFs(fs),
|
|
651
|
+
folder: consumer.root,
|
|
652
|
+
root: '',
|
|
653
|
+
pino: consumer.log,
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
// GENERATION RUNS FROM `.sdk`.
|
|
657
|
+
//
|
|
658
|
+
// Components copy their template tree with a CWD-RELATIVE path
|
|
659
|
+
// (`Copy({ from: 'tm/<lang>' })`), because that is how a consumer runs it —
|
|
660
|
+
// `npm run generate` from the `.sdk` directory. Run it from anywhere else
|
|
661
|
+
// and the first feature copy fails on a `tm/...` path that does not exist
|
|
662
|
+
// relative to the caller's cwd, naming the template rather than the reason.
|
|
663
|
+
//
|
|
664
|
+
// The chdir is restored even when generation throws, because a test runner
|
|
665
|
+
// shares one process across suites and a leaked cwd breaks whatever runs
|
|
666
|
+
// next, somewhere else entirely.
|
|
667
|
+
const prevcwd = process.cwd()
|
|
668
|
+
process.chdir(consumer.sdk)
|
|
669
|
+
|
|
670
|
+
let res: any
|
|
671
|
+
try {
|
|
672
|
+
res = await sdkgen.generate({
|
|
673
|
+
model: opts.model,
|
|
674
|
+
root: opts.root ?? defaultRoot(),
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
process.chdir(prevcwd)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (true !== res.ok) {
|
|
682
|
+
throw new Error('testkit: generation failed: ' + JSON.stringify(res))
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// KEYS ARE COMPARED AS THE VOLUME SPELLS THEM, NOT AS THE OS DOES.
|
|
686
|
+
//
|
|
687
|
+
// memfs is a POSIX volume: it stores `/a/b`, never `C:/a/b`. So a generated
|
|
688
|
+
// path comes back with its DRIVE LETTER DROPPED, while `consumer.root` — a
|
|
689
|
+
// real Windows path — still has one. `Path.relative` between the two does
|
|
690
|
+
// not merely fail; it resolves the drive-less path against the CWD and
|
|
691
|
+
// returns something confidently wrong (`D:\Users\…` for a root on `C:`).
|
|
692
|
+
//
|
|
693
|
+
// `external.test.ts` hit this first and its `norm` is the rule being reused
|
|
694
|
+
// here: lose the separators AND the drive letter before comparing. Prefix
|
|
695
|
+
// arithmetic rather than `Path.relative`, because the whole problem is that
|
|
696
|
+
// these are volume keys and not OS paths.
|
|
697
|
+
//
|
|
698
|
+
// On POSIX both transformations are identity, so this changes nothing there.
|
|
699
|
+
const rootkey = volumeKey(consumer.root)
|
|
700
|
+
|
|
701
|
+
const files: Record<string, string> = {}
|
|
702
|
+
for (const [path, content] of Object.entries(vol.toJSON() as Record<string, string>)) {
|
|
703
|
+
const key = volumeKey(path)
|
|
704
|
+
|
|
705
|
+
// A KEY OUTSIDE THE ROOT IS A BUG HERE, NOT A RESULT.
|
|
706
|
+
//
|
|
707
|
+
// It means the string generation wrote under and the string being stripped
|
|
708
|
+
// off have diverged, and the caller would otherwise get a file map keyed
|
|
709
|
+
// by absolute path — which reads as "my component never ran" rather than
|
|
710
|
+
// as a path problem, and is exactly how this presented on Windows.
|
|
711
|
+
if (key !== rootkey && !key.startsWith(rootkey + '/')) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
'testkit: generated path is not under the consumer root, so the ' +
|
|
714
|
+
'result cannot be keyed.\n root: ' + consumer.root +
|
|
715
|
+
'\n path: ' + path +
|
|
716
|
+
'\n compared as: ' + rootkey + ' vs ' + key +
|
|
717
|
+
'\nThese must agree once separators and any drive letter are ' +
|
|
718
|
+
'normalised — memfs stores volume keys, not OS paths.')
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const rel = key === rootkey ? '' : key.slice(rootkey.length + 1)
|
|
722
|
+
|
|
723
|
+
if (rel.startsWith('.jostraca/') || rel.includes('/.jostraca/')) continue
|
|
724
|
+
files[rel] = content
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const allow = opts.allowPlaceholder ?? (() => false)
|
|
728
|
+
const leaks: string[] = []
|
|
729
|
+
for (const [path, content] of Object.entries(files)) {
|
|
730
|
+
if ('string' !== typeof content) continue
|
|
731
|
+
|
|
732
|
+
for (const token of PLACEHOLDERS) {
|
|
733
|
+
if (content.includes(token) && !allow(path, token)) {
|
|
734
|
+
leaks.push(path + ': ' + token)
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const ref = content.match(PLACEHOLDER_REF)
|
|
739
|
+
if (null != ref && !allow(path, ref[0])) {
|
|
740
|
+
leaks.push(path + ': ' + ref[0])
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return { files, leaks: leaks.sort() }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
// Write to memfs, read through to the real project.
|
|
749
|
+
function layeredFs(mem: any): any {
|
|
750
|
+
const readThrough = (name: string) => (path: any, ...rest: any[]) => {
|
|
751
|
+
const target = mem.existsSync(path) ? mem : Fs
|
|
752
|
+
return (target as any)[name](path, ...rest)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
...mem,
|
|
757
|
+
existsSync: (path: any) => mem.existsSync(path) || Fs.existsSync(path),
|
|
758
|
+
readFileSync: readThrough('readFileSync'),
|
|
759
|
+
readdirSync: readThrough('readdirSync'),
|
|
760
|
+
statSync: readThrough('statSync'),
|
|
761
|
+
realpathSync: readThrough('realpathSync'),
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
// The Root a create-sdkgen consumer has: per target, a folder holding the
|
|
767
|
+
// entity, feature, main, readme, agentguide and test phases.
|
|
768
|
+
//
|
|
769
|
+
// Deliberately minimal. A package author testing their own target wants to
|
|
770
|
+
// know that THEIR components ran, not to re-test the scaffold's Root — and a
|
|
771
|
+
// kit that shipped an elaborate Root would make its own behaviour part of
|
|
772
|
+
// every package's test result.
|
|
773
|
+
function defaultRoot(): any {
|
|
774
|
+
const { cmp, each, names, Project, Folder } = require('jostraca')
|
|
775
|
+
const { Main, Entity, Feature, Test, Readme, AgentGuide } = require('./sdkgen')
|
|
776
|
+
|
|
777
|
+
return cmp(function Root(props: any) {
|
|
778
|
+
const { model, ctx$ } = props
|
|
779
|
+
|
|
780
|
+
model.const = model.const || { name: model.name }
|
|
781
|
+
names(model.const, model.name)
|
|
782
|
+
if (null == model.const.year) model.const.year = new Date().getFullYear()
|
|
783
|
+
names(model, model.name)
|
|
784
|
+
|
|
785
|
+
ctx$.model = model
|
|
786
|
+
ctx$.stdrep = ctx$.stdrep || {}
|
|
787
|
+
names(ctx$.stdrep, model.Name, 'Project' + 'Name')
|
|
788
|
+
|
|
789
|
+
const target = model.main[KIT].target || {}
|
|
790
|
+
const feature = model.main[KIT].feature || {}
|
|
791
|
+
const entity = model.main[KIT].entity || {}
|
|
792
|
+
|
|
793
|
+
Project({}, () => {
|
|
794
|
+
each(target)
|
|
795
|
+
.filter((t: any) => t && false !== t.active)
|
|
796
|
+
.map((t: any) => {
|
|
797
|
+
names(t, t.name)
|
|
798
|
+
|
|
799
|
+
const phase = t.phase || {}
|
|
800
|
+
const on = (n: string) => false !== (phase[n] && phase[n].active)
|
|
801
|
+
|
|
802
|
+
Folder({ name: t.name }, () => {
|
|
803
|
+
if (on('entity')) {
|
|
804
|
+
each(entity)
|
|
805
|
+
.filter((e: any) => e && false !== e.active)
|
|
806
|
+
.map((e: any) => {
|
|
807
|
+
names(e, e.name)
|
|
808
|
+
Entity({ target: t, entity: e })
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (on('feature')) {
|
|
813
|
+
each(feature)
|
|
814
|
+
.filter((f: any) => f && f.active)
|
|
815
|
+
.map((f: any) => {
|
|
816
|
+
names(f, f.name)
|
|
817
|
+
Feature({ target: t, feature: f })
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
Main({ target: t })
|
|
822
|
+
|
|
823
|
+
if (on('readme')) Readme({ target: t })
|
|
824
|
+
if (on('agentguide')) AgentGuide({ target: t })
|
|
825
|
+
if (on('test')) Test({ target: t })
|
|
826
|
+
})
|
|
827
|
+
})
|
|
828
|
+
})
|
|
829
|
+
})
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
// THE PARITY TIER A PACKAGE DECLARES, from its manifest.
|
|
834
|
+
//
|
|
835
|
+
// `ts/test/parity.test.ts` owns the tier declaration for BUNDLED targets, and
|
|
836
|
+
// §18.4a refused to duplicate that map into the bundled manifest for exactly
|
|
837
|
+
// the reason this function exists to eventually resolve: the manifest should
|
|
838
|
+
// become the source and the parity suite should read it, not the reverse. For
|
|
839
|
+
// an EXTERNAL package there is no such conflict — its manifest is the only
|
|
840
|
+
// place its tier can live, so reading it here is the whole mechanism.
|
|
841
|
+
function manifestParity(pkgRoot: string): Record<string, string> {
|
|
842
|
+
const file = Path.join(pkgRoot, 'sdkgen-package.json')
|
|
843
|
+
if (!Fs.existsSync(file)) return {}
|
|
844
|
+
const manifest = JSON.parse(Fs.readFileSync(file, 'utf8'))
|
|
845
|
+
return manifest.parity ?? {}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
export type {
|
|
850
|
+
Consumer,
|
|
851
|
+
StageOptions,
|
|
852
|
+
GenerateOptions,
|
|
853
|
+
GenerateResult,
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export {
|
|
857
|
+
PLACEHOLDERS,
|
|
858
|
+
SDKGEN_ROOT,
|
|
859
|
+
volumeKey,
|
|
860
|
+
stageConsumer,
|
|
861
|
+
generateInto,
|
|
862
|
+
manifestParity,
|
|
863
|
+
}
|