@voxgig/sdkgen 3.4.6 → 3.5.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.
Files changed (198) hide show
  1. package/bin/voxgig-sdkgen +78 -10
  2. package/dist/action/action.d.ts +5 -2
  3. package/dist/action/action.js +113 -6
  4. package/dist/action/action.js.map +1 -1
  5. package/dist/action/check.d.ts +15 -0
  6. package/dist/action/check.js +347 -0
  7. package/dist/action/check.js.map +1 -0
  8. package/dist/action/dispatch.d.ts +8 -0
  9. package/dist/action/dispatch.js +74 -0
  10. package/dist/action/dispatch.js.map +1 -0
  11. package/dist/action/docs.d.ts +4 -0
  12. package/dist/action/docs.js +236 -0
  13. package/dist/action/docs.js.map +1 -0
  14. package/dist/action/doctor.d.ts +5 -2
  15. package/dist/action/doctor.js +526 -74
  16. package/dist/action/doctor.js.map +1 -1
  17. package/dist/action/feature.js +96 -17
  18. package/dist/action/feature.js.map +1 -1
  19. package/dist/action/kind.d.ts +30 -0
  20. package/dist/action/kind.js +258 -0
  21. package/dist/action/kind.js.map +1 -0
  22. package/dist/action/package.d.ts +25 -0
  23. package/dist/action/package.js +852 -0
  24. package/dist/action/package.js.map +1 -0
  25. package/dist/action/resolve.d.ts +18 -0
  26. package/dist/action/resolve.js +280 -0
  27. package/dist/action/resolve.js.map +1 -0
  28. package/dist/action/target.d.ts +7 -1
  29. package/dist/action/target.js +240 -97
  30. package/dist/action/target.js.map +1 -1
  31. package/dist/cmp/Docs.d.ts +4 -0
  32. package/dist/cmp/Docs.js +98 -0
  33. package/dist/cmp/Docs.js.map +1 -0
  34. package/dist/cmp/ExternalDocs.d.ts +2 -0
  35. package/dist/cmp/ExternalDocs.js +43 -0
  36. package/dist/cmp/ExternalDocs.js.map +1 -0
  37. package/dist/cmp/ReadmeTop.js +5 -1
  38. package/dist/cmp/ReadmeTop.js.map +1 -1
  39. package/dist/helpers/definition.d.ts +5 -0
  40. package/dist/helpers/definition.js +72 -0
  41. package/dist/helpers/definition.js.map +1 -0
  42. package/dist/helpers/featureSource.d.ts +8 -2
  43. package/dist/helpers/featureSource.js +61 -12
  44. package/dist/helpers/featureSource.js.map +1 -1
  45. package/dist/helpers/junk.d.ts +11 -0
  46. package/dist/helpers/junk.js +149 -0
  47. package/dist/helpers/junk.js.map +1 -0
  48. package/dist/helpers/manifest.d.ts +50 -0
  49. package/dist/helpers/manifest.js +328 -0
  50. package/dist/helpers/manifest.js.map +1 -0
  51. package/dist/helpers/modelcheck.d.ts +22 -0
  52. package/dist/helpers/modelcheck.js +203 -0
  53. package/dist/helpers/modelcheck.js.map +1 -0
  54. package/dist/helpers/semver.d.ts +4 -0
  55. package/dist/helpers/semver.js +174 -0
  56. package/dist/helpers/semver.js.map +1 -0
  57. package/dist/helpers/shipped.d.ts +4 -0
  58. package/dist/helpers/shipped.js +36 -0
  59. package/dist/helpers/shipped.js.map +1 -0
  60. package/dist/helpers/stdrep.d.ts +9 -1
  61. package/dist/helpers/stdrep.js +22 -0
  62. package/dist/helpers/stdrep.js.map +1 -1
  63. package/dist/sdkgen.d.ts +15 -3
  64. package/dist/sdkgen.js +233 -67
  65. package/dist/sdkgen.js.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/dist/types.d.ts +10 -2
  68. package/dist/utility.js +17 -0
  69. package/dist/utility.js.map +1 -1
  70. package/model/sdkgen.aontu +69 -0
  71. package/package.json +3 -2
  72. package/project/.sdk/model/feature/audit.aontu +1 -0
  73. package/project/.sdk/model/feature/cache.aontu +1 -0
  74. package/project/.sdk/model/feature/clienttrack.aontu +1 -0
  75. package/project/.sdk/model/feature/debug.aontu +1 -0
  76. package/project/.sdk/model/feature/idempotency.aontu +1 -0
  77. package/project/.sdk/model/feature/log.aontu +1 -0
  78. package/project/.sdk/model/feature/metrics.aontu +1 -0
  79. package/project/.sdk/model/feature/netsim.aontu +1 -0
  80. package/project/.sdk/model/feature/paging.aontu +1 -0
  81. package/project/.sdk/model/feature/proxy.aontu +1 -0
  82. package/project/.sdk/model/feature/ratelimit.aontu +1 -0
  83. package/project/.sdk/model/feature/rbac.aontu +1 -0
  84. package/project/.sdk/model/feature/retry.aontu +1 -0
  85. package/project/.sdk/model/feature/streaming.aontu +1 -0
  86. package/project/.sdk/model/feature/telemetry.aontu +1 -0
  87. package/project/.sdk/model/feature/test.aontu +1 -0
  88. package/project/.sdk/model/feature/timeout.aontu +1 -0
  89. package/project/.sdk/model/target/c.aontu +1 -0
  90. package/project/.sdk/model/target/clojure.aontu +1 -0
  91. package/project/.sdk/model/target/cpp.aontu +1 -0
  92. package/project/.sdk/model/target/dart.aontu +1 -0
  93. package/project/.sdk/model/target/elixir.aontu +1 -0
  94. package/project/.sdk/model/target/go-cli.aontu +1 -0
  95. package/project/.sdk/model/target/go-mcp.aontu +1 -0
  96. package/project/.sdk/model/target/go.aontu +1 -0
  97. package/project/.sdk/model/target/haskell.aontu +1 -0
  98. package/project/.sdk/model/target/java.aontu +1 -0
  99. package/project/.sdk/model/target/js.aontu +1 -0
  100. package/project/.sdk/model/target/kotlin.aontu +1 -0
  101. package/project/.sdk/model/target/lean.aontu +1 -0
  102. package/project/.sdk/model/target/lua.aontu +1 -0
  103. package/project/.sdk/model/target/ocaml.aontu +1 -0
  104. package/project/.sdk/model/target/perl.aontu +1 -0
  105. package/project/.sdk/model/target/php.aontu +1 -0
  106. package/project/.sdk/model/target/py-data.aontu +1 -0
  107. package/project/.sdk/model/target/py.aontu +1 -0
  108. package/project/.sdk/model/target/rb.aontu +1 -0
  109. package/project/.sdk/model/target/rust.aontu +1 -0
  110. package/project/.sdk/model/target/scala.aontu +1 -0
  111. package/project/.sdk/model/target/seneca-provider.aontu +1 -0
  112. package/project/.sdk/model/target/zig.aontu +1 -0
  113. package/project/.sdk/src/cmp/c/Config_c.ts +44 -0
  114. package/project/.sdk/src/cmp/c/fragment/Main.fragment.c +3 -1
  115. package/project/.sdk/src/cmp/clojure/Config_clojure.ts +37 -42
  116. package/project/.sdk/src/cmp/clojure/utility_clojure.ts +42 -0
  117. package/project/.sdk/src/cmp/cpp/Config_cpp.ts +18 -0
  118. package/project/.sdk/src/cmp/csharp/Config_csharp.ts +125 -52
  119. package/project/.sdk/src/cmp/csharp/fragment/Main.fragment.cs +3 -1
  120. package/project/.sdk/src/cmp/csharp/utility_csharp.ts +94 -6
  121. package/project/.sdk/src/cmp/dart/Config_dart.ts +56 -3
  122. package/project/.sdk/src/cmp/dart/fragment/Config.data.fragment.dart +74 -0
  123. package/project/.sdk/src/cmp/dart/fragment/Config.fragment.dart +6 -9
  124. package/project/.sdk/src/cmp/dart/fragment/Main.fragment.dart +16 -5
  125. package/project/.sdk/src/cmp/dart/utility_dart.ts +18 -0
  126. package/project/.sdk/src/cmp/elixir/Config_elixir.ts +128 -13
  127. package/project/.sdk/src/cmp/elixir/fragment/Main.fragment.ex +3 -1
  128. package/project/.sdk/src/cmp/elixir/utility_elixir.ts +6 -0
  129. package/project/.sdk/src/cmp/haskell/Config_haskell.ts +35 -52
  130. package/project/.sdk/src/cmp/haskell/Package_haskell.ts +1 -0
  131. package/project/.sdk/src/cmp/haskell/Test_haskell.ts +1 -1
  132. package/project/.sdk/src/cmp/haskell/utility_haskell.ts +15 -5
  133. package/project/.sdk/src/cmp/java/Config_java.ts +21 -0
  134. package/project/.sdk/src/cmp/kotlin/Config_kotlin.ts +15 -0
  135. package/project/.sdk/src/cmp/ocaml/Config_ocaml.ts +42 -39
  136. package/project/.sdk/src/cmp/perl/Config_perl.ts +15 -0
  137. package/project/.sdk/src/cmp/perl/fragment/Main.fragment.pm +3 -1
  138. package/project/.sdk/src/cmp/rust/Config_rust.ts +44 -0
  139. package/project/.sdk/src/cmp/rust/fragment/Main.fragment.rs +4 -2
  140. package/project/.sdk/src/cmp/scala/Config_scala.ts +15 -0
  141. package/project/.sdk/src/cmp/swift/Config_swift.ts +17 -0
  142. package/project/.sdk/src/cmp/swift/fragment/Main.fragment.swift +3 -1
  143. package/project/.sdk/src/cmp/zig/Config_zig.ts +44 -0
  144. package/project/.sdk/src/cmp/zig/TestEntity_zig.ts +9 -2
  145. package/project/.sdk/src/cmp/zig/fragment/Main.fragment.zig +31 -18
  146. package/project/.sdk/tm/c/core/sdk.h +1 -0
  147. package/project/.sdk/tm/c/utility/make_options.c +5 -1
  148. package/project/.sdk/tm/clojure/src/sdk/core.clj +51 -3
  149. package/project/.sdk/tm/cpp/core/types.hpp +4 -1
  150. package/project/.sdk/tm/cpp/utility/pipeline.hpp +5 -1
  151. package/project/.sdk/tm/csharp/test/PipelineTest.cs +15 -0
  152. package/project/.sdk/tm/csharp/utility/MakeOptions.cs +67 -1
  153. package/project/.sdk/tm/dart/lib/utility/MakeOptionsUtility.dart +43 -0
  154. package/project/.sdk/tm/elixir/lib/projectname/utility.ex +58 -2
  155. package/project/.sdk/tm/haskell/{test/TestJson.hs → src/SdkJson.hs} +8 -4
  156. package/project/.sdk/tm/haskell/src/SdkRuntime.hs +55 -2
  157. package/project/.sdk/tm/haskell/test/Runner.hs +1 -1
  158. package/project/.sdk/tm/haskell/test/StructCorpus.hs +1 -1
  159. package/project/.sdk/tm/java/core/SdkClient.java +3 -1
  160. package/project/.sdk/tm/java/utility/MakeOptions.java +5 -1
  161. package/project/.sdk/tm/kotlin/core/SdkClient.kt +3 -1
  162. package/project/.sdk/tm/kotlin/utility/MakeOptions.kt +5 -1
  163. package/project/.sdk/tm/ocaml/Makefile +3 -2
  164. package/project/.sdk/tm/ocaml/sdk_json.ml +106 -0
  165. package/project/.sdk/tm/ocaml/sdk_runtime.ml +56 -0
  166. package/project/.sdk/tm/ocaml/test/struct_corpus.ml +4 -86
  167. package/project/.sdk/tm/perl/utility/make_options.pm +5 -1
  168. package/project/.sdk/tm/rust/utility/make_options.rs +79 -1
  169. package/project/.sdk/tm/scala/core/SdkClient.scala +3 -1
  170. package/project/.sdk/tm/scala/utility/Make.scala +5 -1
  171. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/MakeOptions.swift +5 -1
  172. package/project/.sdk/tm/zig/core/utility.zig +85 -1
  173. package/project/.sdk/tm/zig/test/gotcha_test.zig +8 -8
  174. package/project/sdkgen-package.json +57 -0
  175. package/src/action/action.ts +136 -6
  176. package/src/action/check.ts +495 -0
  177. package/src/action/dispatch.ts +105 -0
  178. package/src/action/docs.ts +310 -0
  179. package/src/action/doctor.ts +692 -81
  180. package/src/action/feature.ts +109 -17
  181. package/src/action/kind.ts +375 -0
  182. package/src/action/package.ts +1173 -0
  183. package/src/action/resolve.ts +362 -0
  184. package/src/action/target.ts +290 -108
  185. package/src/cmp/Docs.ts +125 -0
  186. package/src/cmp/ExternalDocs.ts +54 -0
  187. package/src/cmp/ReadmeTop.ts +6 -1
  188. package/src/helpers/definition.ts +82 -0
  189. package/src/helpers/featureSource.ts +83 -13
  190. package/src/helpers/junk.ts +168 -0
  191. package/src/helpers/manifest.ts +474 -0
  192. package/src/helpers/modelcheck.ts +262 -0
  193. package/src/helpers/semver.ts +211 -0
  194. package/src/helpers/shipped.ts +41 -0
  195. package/src/helpers/stdrep.ts +70 -0
  196. package/src/sdkgen.ts +294 -66
  197. package/src/types.ts +33 -3
  198. package/src/utility.ts +17 -0
@@ -44,18 +44,43 @@ import type {
44
44
 
45
45
  import { SdkGenError } from '../utility'
46
46
 
47
- import { templateReplacements } from '../helpers/stdrep'
47
+ import { templateReplacements, provenanceReplace } from '../helpers/stdrep'
48
48
 
49
49
  import {
50
- resolveTarget,
51
50
  trimFeatures,
51
+ aliasCmpText,
52
+ aliasCmpName,
52
53
  } from './target'
53
54
 
55
+ // `aliasModelText` is no longer named here: the model-file comparison asks
56
+ // the registry for the kind's own `rename`, so a kind that renames
57
+ // differently is handled without this file knowing how.
58
+ import { recordedRef, KINDS, kindDef, kindTrees } from './kind'
59
+ import type { TreeDef } from './kind'
60
+
61
+ import { resolveSource } from './resolve'
62
+ import type { Source } from './resolve'
63
+
64
+ import { definitionPath } from '../helpers/definition'
65
+
66
+ import { findFeatureSources } from '../helpers/featureSource'
67
+
68
+ import { isJunk } from '../helpers/junk'
69
+
54
70
 
55
71
  // jostraca's Copy walk skips these (IGNORED_RE in CopyOp) — editor backups and
56
72
  // deliberately-disabled templates never reach a project, so they are not drift.
73
+ //
74
+ // `isJunk` is the other half of the same statement: what the WRITER refuses to
75
+ // copy, this reader must refuse to miss. A `__pycache__` in the scaffold would
76
+ // otherwise be reported as missing from every project on earth, and one in a
77
+ // project as an unexplained extra file. See helpers/junk.
57
78
  const IGNORED_RE = /(~|-jostraca-off)$/
58
79
 
80
+ function ignoredEntry(name: string): boolean {
81
+ return IGNORED_RE.test(name) || isJunk(name)
82
+ }
83
+
59
84
  // Extensions jostraca copies byte-for-byte. Comparing them as text would
60
85
  // report spurious differences, so they are compared by raw bytes.
61
86
  const BINARY_RE = /\.(png|jpg|jpeg|gif|ico|pdf|zip|gz|woff2?|ttf|eot|wasm)$/i
@@ -107,6 +132,18 @@ type DoctorReport = {
107
132
  // wiring never calls. Informational: opting out is legitimate.
108
133
  unwired: string[]
109
134
 
135
+ // A copy that differs from the scaffold ONLY in its provenance lines —
136
+ // written before `base`/`origname` were stamped, so the project changed
137
+ // nothing and `target add` will bring it up to date. Informational: this
138
+ // exists so the provenance rollout does not read as a fork in every
139
+ // project at once.
140
+ resyncPending: string[]
141
+
142
+ // An ALIASED target's model file differs from what its origin would
143
+ // produce. Informational: that file is project-owned, and differentiating
144
+ // it is what an alias is FOR.
145
+ aliasedDiff: string[]
146
+
110
147
  // True when nothing in the first four categories was found.
111
148
  ok: boolean
112
149
  }
@@ -136,9 +173,19 @@ async function cmd_doctor_check(_args: string[], actx: ActionContext): Promise<A
136
173
  }
137
174
 
138
175
 
176
+ // Narrows the check to particular items — `package update` runs doctor as
177
+ // its pre-check and needs the verdict for ONE package's items, not the
178
+ // project's. Reusing the real comparison rather than writing a second one is
179
+ // the point: a gate that decides whether to overwrite a project's files must
180
+ // not have its own idea of what counts as a difference.
181
+ type DoctorScope = (kind: string, name: string) => boolean
182
+
183
+
139
184
  // Code API. Returns the report; the CLI turns a non-ok report into a
140
185
  // non-zero exit so this can gate CI.
141
- async function doctor(actx: ActionContext): Promise<ActionResult> {
186
+ async function doctor(
187
+ actx: ActionContext, scope?: DoctorScope,
188
+ ): Promise<ActionResult> {
142
189
  const log = actx.log
143
190
  const fs = actx.fs()
144
191
  const model = actx.model
@@ -146,39 +193,94 @@ async function doctor(actx: ActionContext): Promise<ActionResult> {
146
193
 
147
194
  const report: DoctorReport = {
148
195
  forked: [], edited: [], stale: [], missing: [], additive: [],
149
- unwired: [], ok: true,
196
+ unwired: [], resyncPending: [], aliasedDiff: [], ok: true,
150
197
  }
151
198
 
152
- const targets = Object.keys(model?.main?.[KIT]?.target ?? {})
199
+ // EVERY KIND, not just targets.
200
+ //
201
+ // `add` writes a copied model file for each kind — `model/target/<t>.aontu`
202
+ // and `model/feature/<f>.aontu` alike — and overwrites it on every resync.
203
+ // Only the target one was ever compared, so a hand-edit to an installed
204
+ // FEATURE definition read as perfectly in sync and was silently reverted by
205
+ // the next `target add` (which re-runs `feature add` for every active
206
+ // feature). That is exactly the failure `checkTargetModel` was added for,
207
+ // left open for the other half of what add owns.
208
+ //
209
+ // Driven off the registry, so a kind added later is checked without
210
+ // anything here changing. The per-kind EXTRAS — a target's `src/cmp` and
211
+ // `tm` trees — stay in that kind's own check, because they are genuinely
212
+ // different work rather than the same work with different strings.
213
+ const kinds = Object.keys(KINDS).sort()
214
+
215
+ const counts: Record<string, number> = {}
216
+ for (const kind of kinds) {
217
+ counts[kind] = Object.keys((model as any)?.main?.[KIT]?.[kind] ?? {}).length
218
+ }
153
219
 
154
- log.info({ point: 'doctor-start', targets: targets.length })
220
+ log.info({ point: 'doctor-start', targets: counts.target ?? 0, ...counts })
155
221
 
156
- for (const tname of targets) {
157
- const declared = (model as any)?.main?.[KIT]?.target?.[tname]
222
+ // Targets resolved ONCE, before the kind loop, because the feature checks
223
+ // need them too: a feature's per-target source lives under the name the
224
+ // target has in its own source, so finding it means knowing where each
225
+ // target came from. Resolving per (feature, target) pair would re-read
226
+ // every package manifest dozens of times and warn repeatedly about the
227
+ // same unresolvable target.
228
+ //
229
+ // Resolved even when the run is SCOPED past them — a scoped check of one
230
+ // feature still has to look in every target's tree for that feature's
231
+ // files.
232
+ const targets = new Map<string, Source>()
158
233
 
159
- // The target model records where it came from (`base`, written by
160
- // `target add`). Fall back to the bundled scaffold.
161
- const tref = (declared && declared.base) ?
162
- Path.join(declared.base, '..', tname) : tname
234
+ for (const tname of
235
+ Object.keys((model as any)?.main?.[KIT]?.target ?? {}).sort()) {
236
+ const source = resolveDeclared('target', tname, actx)
163
237
 
164
- let resolved: any
165
- try {
166
- resolved = resolveTarget(tref, { folder: root, fs: () => fs })
167
- }
168
- catch (err: any) {
169
- log.warn({
170
- point: 'doctor-target-unresolved', target: tname, err: err.message,
171
- note: tname + ': cannot find its scaffold (' + err.message + ')'
172
- })
173
- continue
238
+ if (null != source) {
239
+ targets.set(tname, source)
174
240
  }
241
+ }
242
+
243
+ for (const kind of kinds) {
244
+ const items = Object.keys((model as any)?.main?.[KIT]?.[kind] ?? {}).sort()
175
245
 
176
- if (null != resolved) {
177
- checkTarget(actx, resolved, report)
246
+ for (const name of items) {
247
+ if (null != scope && !scope(kind, name)) {
248
+ continue
249
+ }
250
+
251
+ const source = 'target' === kind ?
252
+ targets.get(name) : resolveDeclared(kind, name, actx)
253
+
254
+ if (null == source) {
255
+ continue
256
+ }
257
+
258
+ if ('target' === kind) {
259
+ checkTarget(actx, source, report)
260
+ }
261
+
262
+ if ('docs' === kind) {
263
+ checkDocs(actx, source, report)
264
+ }
265
+
266
+ // Only an ACTIVE feature has source copied out; what an inactive one
267
+ // left behind is stale, and the target walk reports it as such.
268
+ if ('feature' === kind &&
269
+ false !== (model as any)?.main?.[KIT]?.feature?.[name]?.active) {
270
+ checkFeatureSource(actx, source, targets, report)
271
+ }
272
+
273
+ checkItemModel(actx, kind, source, report)
178
274
  }
179
275
  }
180
276
 
181
- checkWiring(actx, report)
277
+ // Root-component wiring is a property of the PROJECT, not of any item, so a
278
+ // scoped run has no business reporting on it — `package update` asking "is
279
+ // this package's copy clean" should not also say the project never wired in
280
+ // `ReadmeTop`.
281
+ if (null == scope) {
282
+ checkWiring(actx, report)
283
+ }
182
284
 
183
285
  report.ok = 0 === report.forked.length + report.edited.length +
184
286
  report.stale.length + report.missing.length
@@ -190,6 +292,8 @@ async function doctor(actx: ActionContext): Promise<ActionResult> {
190
292
  ['missing', 'MISSING (would be written by `target add`)'],
191
293
  ['additive', 'additive (project-owned, not drift)'],
192
294
  ['unwired', 'NOT WIRED IN (root capability this project is missing)'],
295
+ ['resyncPending', 'RESYNC PENDING (predates provenance; `target add` updates it)'],
296
+ ['aliasedDiff', 'aliased model differs from its origin (project-owned, not drift)'],
193
297
  ] as [keyof DoctorReport, string][]) {
194
298
  for (const file of (report[kind] as string[])) {
195
299
  log.info({ point: 'doctor-finding', kind, file, note: note + ': ' + file })
@@ -205,6 +309,8 @@ async function doctor(actx: ActionContext): Promise<ActionResult> {
205
309
  missing: report.missing.length,
206
310
  additive: report.additive.length,
207
311
  unwired: report.unwired.length,
312
+ resyncPending: report.resyncPending.length,
313
+ aliasedDiff: report.aliasedDiff.length,
208
314
  note: report.ok ?
209
315
  ('.sdk matches the scaffold (' + report.additive.length + ' additive)') :
210
316
  ('.sdk has drifted: ' + report.forked.length + ' forked, ' +
@@ -212,7 +318,7 @@ async function doctor(actx: ActionContext): Promise<ActionResult> {
212
318
  report.missing.length + ' missing')
213
319
  })
214
320
 
215
- return { report } as any
321
+ return { report }
216
322
  }
217
323
 
218
324
 
@@ -247,8 +353,53 @@ function checkWiring(actx: ActionContext, report: DoctorReport) {
247
353
  }
248
354
 
249
355
 
250
- function checkTarget(actx: ActionContext, resolved: any, report: DoctorReport) {
251
- const { tname, tfolder, torigname } = resolved
356
+ // Where one declared item came from, or undefined with the reason said out
357
+ // loud.
358
+ //
359
+ // The model records `base` and `origname` (written by add), and `origname` is
360
+ // what makes an ALIAS checkable: the ref used to be rebuilt as
361
+ // `<base>/../<name>`, which names the INSTALLED item — so `target add ts~ts2`
362
+ // sent doctor looking for a `ts2` scaffold that does not exist, both trees
363
+ // walked empty, and every component read as `additive` while every template
364
+ // read as `stale` (a FAILING category). Real edits were undetectable and
365
+ // doctor went red on noise.
366
+ //
367
+ // A bare name falls back to the bundled scaffold, for a copy predating
368
+ // provenance.
369
+ function resolveDeclared(
370
+ kind: string, name: string, actx: ActionContext,
371
+ ): Source | undefined {
372
+ const declared: any = (actx.model as any)?.main?.[KIT]?.[kind]?.[name]
373
+ const ref = recordedRef(declared, name) || name
374
+
375
+ try {
376
+ // WITH THE LOG. Resolution reads the source's package manifest, and an
377
+ // unusable one is reported only through `ctx$.log`. Without it doctor
378
+ // silently lost the package name — and then reported every item from that
379
+ // package as FORKED, because the expected text no longer carries the
380
+ // `package:` line the copy has, with nothing anywhere saying why. That is
381
+ // accurate (the next add really would strip the line) but unactionable,
382
+ // which is the same defect as being wrong.
383
+ return resolveSource(
384
+ ref, kind, { folder: actx.folder, fs: actx.fs, log: actx.log })
385
+ }
386
+ catch (err: any) {
387
+ actx.log.warn({
388
+ point: 'doctor-source-unresolved', kind, [kind]: name, err: err.message,
389
+ note: name + ': cannot find its ' + kind + ' source (' +
390
+ err.message + ')'
391
+ })
392
+ return undefined
393
+ }
394
+ }
395
+
396
+
397
+ function checkTarget(
398
+ actx: ActionContext, resolved: Source, report: DoctorReport,
399
+ ) {
400
+ const tname = resolved.name
401
+ const tfolder = resolved.folder
402
+ const torigname = resolved.origname
252
403
  const fs = actx.fs()
253
404
  const model = actx.model
254
405
  const root = actx.folder
@@ -258,14 +409,29 @@ function checkTarget(actx: ActionContext, resolved: any, report: DoctorReport) {
258
409
  //
259
410
  // src/cmp — copied verbatim, so a byte compare is the truth.
260
411
  // tm — copied through jostraca's template(), with ProjectName.
261
- const trees: {
262
- project: string, scaffold: string, replace: any, kind: 'forked' | 'edited',
263
- }[] = [
412
+ // An ALIASED install renames every `<Cmp>_<origname>` component to
413
+ // `<Cmp>_<alias>` and rewrites the origin name inside the file (sibling
414
+ // imports, the __dirname-relative fragment path), because components are
415
+ // dispatched by the convention `cmp/<t>/Main_<t>`. doctor has to apply the
416
+ // same two transforms or it expects the ORIGIN names in the alias's folder
417
+ // and reports the entire tree as missing — 25 files for `go~go2`, none of
418
+ // them a real finding.
419
+ const aliased = tname !== torigname
420
+
421
+ const renameCmp = aliased ?
422
+ (rel: string) => aliasCmpName(rel, torigname, tname) : undefined
423
+
424
+ const rewriteCmp = aliased ?
425
+ (src: string) => aliasCmpText(src, torigname, tname) : undefined
426
+
427
+ const trees: TreeCompare[] = [
264
428
  {
265
429
  project: Path.join(root, 'src', 'cmp', tname),
266
430
  scaffold: Path.join(tfolder, 'src', 'cmp', torigname),
267
431
  replace: {},
268
432
  kind: 'forked',
433
+ rename: renameCmp,
434
+ rewrite: rewriteCmp,
269
435
  },
270
436
  {
271
437
  project: Path.join(root, 'tm', tname),
@@ -285,18 +451,91 @@ function checkTarget(actx: ActionContext, resolved: any, report: DoctorReport) {
285
451
  ...Object.keys(featuremodel).filter((n: string) => false !== featuremodel[n]?.active),
286
452
  ]))
287
453
 
454
+ // `folder` and `model` matter: the trim catalogue is resolved consumer-side
455
+ // (see featureCatalogue), so a doctor that withheld them would compute a
456
+ // different trim from the one `target add` applied and report correctly
457
+ // trimmed files as missing.
288
458
  const excludes: RegExp[] = trimFeatures(
289
- { log: quietLog(actx.log), fs: () => fs }, tfolder, torigname, tname, features)
459
+ { log: quietLog(actx.log), fs: () => fs, folder: root, model },
460
+ tfolder, torigname, tname, features)
461
+
462
+ compareTrees(actx, report, trees, {
463
+ excludes,
464
+ // Only a TARGET has foreign feature source landing in its tree.
465
+ foreign: (kind: string) => 'edited' === kind ?
466
+ foreignFeatureSource(actx, resolved) : [],
467
+ })
468
+ }
469
+
470
+
471
+ // COMPARE A SET OF TREES against the sources they were copied from.
472
+ //
473
+ // Extracted from `checkTarget` unchanged, because the docs kind needs exactly
474
+ // this and a second copy of it is the defect this workstream keeps producing.
475
+ // What differs per kind stays in the caller: which trees, what the copy
476
+ // substituted, whether a trim excludes part of the source, and whether
477
+ // anything foreign is expected to land in the tree.
478
+ //
479
+ // This is the mechanism behind the rule in CLAUDE.md — anything an add
480
+ // writes, doctor must compare, or the next add silently reverts a project's
481
+ // edit. A kind that declares trees and is not walked here would break it.
482
+ function compareTrees(
483
+ actx: ActionContext,
484
+ report: DoctorReport,
485
+ trees: TreeCompare[],
486
+ opts?: {
487
+ excludes?: RegExp[],
488
+ foreign?: (kind: string) => Iterable<[string, string]>,
489
+ },
490
+ ) {
491
+ const fs = actx.fs()
492
+ const model = actx.model
493
+ const root = actx.folder
494
+ const excludes = opts?.excludes ?? []
290
495
 
291
496
  for (const tree of trees) {
292
497
  // Findings are reported at project-relative paths, the way a maintainer
293
498
  // would type them.
294
499
  const label = Path.relative(root, tree.project).split(Path.sep).join('/') + '/'
295
500
 
296
- const expected = 'edited' === tree.kind ?
501
+ const scaffoldFiles = 'edited' === tree.kind ?
297
502
  walk(fs, tree.scaffold).filter((rel) => !excluded(rel, excludes)) :
298
503
  walk(fs, tree.scaffold)
299
504
 
505
+ // Where each file the project should have COMES FROM: the name it lands
506
+ // under, mapped to the absolute path of the file it was copied from.
507
+ //
508
+ // An absolute path rather than a tree-relative one, because not every
509
+ // expected file comes from THIS tree — see the foreign-feature union
510
+ // below.
511
+ const landed = new Map<string, string>(scaffoldFiles.map(
512
+ (rel: string) => [
513
+ null == tree.rename ? rel : tree.rename(rel),
514
+ Path.join(tree.scaffold, rel),
515
+ ]))
516
+
517
+ // FOREIGN FEATURE SOURCE.
518
+ //
519
+ // A feature supplied by a different package than the target ships its
520
+ // per-target source in ITS OWN `tm/<target>/` overlay, and `feature add`
521
+ // copies that into the project. Compared against the target's scaffold
522
+ // alone, those files are present in the project and absent upstream — so
523
+ // every one of them was reported STALE, a FAILING category. Any project
524
+ // using an external feature had a red `doctor` and nothing wrong with it.
525
+ //
526
+ // They are not stale, they are expected — just expected from somewhere
527
+ // else. Adding them here rather than suppressing them keeps them
528
+ // CHECKED: a hand-edit to a foreign feature's source is still reported,
529
+ // which is what `package update`'s gate needs in order to cover
530
+ // everything the re-add writes.
531
+ const foreign = new Set<string>()
532
+
533
+ for (const [rel, from] of (opts?.foreign?.(tree.kind) ?? [])) {
534
+ landed.set(rel, from)
535
+ foreign.add(rel)
536
+ }
537
+
538
+ const expected = Array.from(landed.keys()).sort()
300
539
  const actual = walk(fs, tree.project)
301
540
 
302
541
  const expectedSet = new Set(expected)
@@ -308,8 +547,19 @@ function checkTarget(actx: ActionContext, resolved: any, report: DoctorReport) {
308
547
  continue
309
548
  }
310
549
 
311
- if (differs(fs, Path.join(tree.scaffold, rel), Path.join(tree.project, rel),
312
- model, tree.replace)) {
550
+ // A foreign feature's file is EXPECTED here (so it is not stale) but
551
+ // compared by `checkFeatureSource`, from the feature's side. Comparing
552
+ // it here too would report it twice on a full run — and, worse, would
553
+ // leave it uncompared on a run scoped to the feature alone, which is
554
+ // exactly when `feature add` is about to rewrite it.
555
+ if (foreign.has(rel)) {
556
+ continue
557
+ }
558
+
559
+ const from = landed.get(rel) as string
560
+
561
+ if (differs(fs, from, Path.join(tree.project, rel),
562
+ model, tree.replace, undefined, tree.rewrite)) {
313
563
  report[tree.kind].push(label + rel)
314
564
  }
315
565
  }
@@ -322,7 +572,8 @@ function checkTarget(actx: ActionContext, resolved: any, report: DoctorReport) {
322
572
  // A component the scaffold has NEVER shipped is the project's own —
323
573
  // the supported way to add a per-target component. Anything else under
324
574
  // a tree `target add` owns is stale output.
325
- const known = fs.existsSync(Path.join(tree.scaffold, rel))
575
+ const known = fs.existsSync(Path.join(tree.scaffold, rel)) ||
576
+ landed.has(rel)
326
577
  if ('forked' === tree.kind && !known) {
327
578
  report.additive.push(label + rel)
328
579
  }
@@ -331,60 +582,387 @@ function checkTarget(actx: ActionContext, resolved: any, report: DoctorReport) {
331
582
  }
332
583
  }
333
584
  }
585
+ }
586
+
587
+
588
+ // A DOCS item's trees.
589
+ //
590
+ // Simpler than a target's in every way that matters here: no trim (a docs
591
+ // item has no per-feature source to leave out), and nothing foreign lands in
592
+ // them (a feature package's overlay targets a TARGET's tree). What is the
593
+ // same is the alias handling — docs components are dispatched by the same
594
+ // `Main_<n>` convention — and the tree paths, which come from the registry
595
+ // rather than being spelled a second time.
596
+ //
597
+ // The optional template tree is skipped when the SOURCE does not ship one:
598
+ // `docs add` did not copy it, so the project is right not to have it.
599
+ function checkDocs(
600
+ actx: ActionContext, resolved: Source, report: DoctorReport,
601
+ ) {
602
+ const fs = actx.fs()
603
+ const root = actx.folder
604
+ const name = resolved.name
605
+ const origname = resolved.origname
606
+ const aliased = name !== origname
607
+
608
+ const dest = kindTrees('docs', name)
609
+ const from = kindTrees('docs', origname)
334
610
 
335
- checkTargetModel(actx, resolved, report)
611
+ const trees: TreeCompare[] = dest.flatMap((tree: TreeDef, i: number) => {
612
+ const scaffold = Path.join(resolved.folder, ...from[i].path.split('/'))
613
+
614
+ if (!fs.existsSync(scaffold)) {
615
+ return []
616
+ }
617
+
618
+ const templated = 'template' === tree.replace
619
+
620
+ return [{
621
+ project: Path.join(root, ...tree.path.split('/')),
622
+ scaffold,
623
+ replace: templated ? templateReplacements(actx.model, name) : {},
624
+ kind: templated ? 'edited' : 'forked',
625
+ rename: aliased && !templated ?
626
+ (rel: string) => aliasCmpName(rel, origname, name) : undefined,
627
+ rewrite: aliased && !templated ?
628
+ (src: string) => aliasCmpText(src, origname, name) : undefined,
629
+ } as TreeCompare]
630
+ })
631
+
632
+ compareTrees(actx, report, trees)
336
633
  }
337
634
 
338
635
 
339
- // The THIRD thing `target add` writes for a target, and the one nothing
340
- // compared: `model/target/<t>.aontu` (src/action/target.ts, the Copy with the
341
- // `'BASE'` replacement).
636
+ // One tree to compare: where it landed, where it came from, what the copy
637
+ // substituted, and how a finding about it is categorised.
638
+ //
639
+ // forked — a verbatim copy, so any difference is a fork of the source
640
+ // edited — a templated copy, so a difference is an edit to the master
641
+ type TreeCompare = {
642
+ project: string
643
+ scaffold: string
644
+ replace: any
645
+ kind: 'forked' | 'edited'
646
+ rename?: (rel: string) => string
647
+ rewrite?: (src: string) => string
648
+ }
649
+
650
+
651
+ // Per-target source that a FEATURE package supplies for this target, as
652
+ // `project-relative path -> the file it was copied from`.
653
+ //
654
+ // Only for features whose source folder is not the target's own: everything
655
+ // the target ships for itself is already in the tree being walked, and
656
+ // listing it twice would compare it against itself.
657
+ //
658
+ // Only for ACTIVE features, because that is what `feature add` copies. A
659
+ // feature switched off later leaves its files behind, and those really are
660
+ // stale — reporting them is the point.
342
661
  //
343
- // It is not scaffolding trivia. That file carries the target's dependency
344
- // set, its `phase` gates, `srcfeature`, `feature.trim` / `feature.fullset`
345
- // and its publish-registry identity and `target add` OVERWRITES it exactly
346
- // as it overwrites src/cmp and tm. So a maintainer who fixes a dep version
347
- // there loses the fix on the next resync with nothing said (the SDK regresses
348
- // with nobody touching it, which is the failure the "a project decision
349
- // belongs in the MODEL" rule exists to prevent), and a project whose copy
350
- // predates a scaffold change carries the old declaration forever while doctor
351
- // reported the target in sync. Both are the drift this command exists to
352
- // name; the file was simply in neither tree it walked.
353
- function checkTargetModel(actx: ActionContext, resolved: any, report: DoctorReport) {
354
- const { tname, tfolder, torigname, base } = resolved
662
+ // Discovered with `findFeatureSources`, the same function the fan-out and the
663
+ // trim use, so doctor's idea of which files belong to a feature cannot drift
664
+ // from the one that put them there.
665
+ function foreignFeatureSource(
666
+ actx: ActionContext, target: Source,
667
+ ): Map<string, string> {
668
+ const out = new Map<string, string>()
669
+
670
+ const features: any = (actx.model as any)?.main?.[KIT]?.feature ?? {}
671
+
672
+ for (const fname of Object.keys(features).sort()) {
673
+ if (false === features[fname]?.active) {
674
+ continue
675
+ }
676
+
677
+ const source = resolveDeclared('feature', fname, actx)
678
+
679
+ if (null == source) {
680
+ continue
681
+ }
682
+
683
+ for (const [rel, from] of overlayFiles(actx, source, target)) {
684
+ out.set(rel, from)
685
+ }
686
+ }
687
+
688
+ return out
689
+ }
690
+
691
+
692
+ // One feature's per-target source, as `tm-relative path -> the file it was
693
+ // copied from`, for source the FEATURE package supplies rather than the
694
+ // target.
695
+ //
696
+ // ONE enumerator, used from both directions: the target walk needs these to
697
+ // know they are expected, and the feature check needs them to compare. Two
698
+ // enumerations would be two ideas of which files a feature owns, and the
699
+ // pair would drift — which is the failure this codebase keeps producing.
700
+ //
701
+ // Empty when the feature's source IS the target's own tree: those files are
702
+ // already in the tree being walked, and listing them would compare them
703
+ // against themselves.
704
+ function overlayFiles(
705
+ actx: ActionContext, feature: Source, target: Source,
706
+ ): Map<string, string> {
707
+ const fs = actx.fs()
708
+ const out = new Map<string, string>()
709
+
710
+ if (feature.folder === target.folder) {
711
+ return out
712
+ }
713
+
714
+ // The feature package's overlay for THIS target, under the name the target
715
+ // has in its own source — an aliased target's templates live at
716
+ // `tm/<origname>`.
717
+ const overlay = Path.join(feature.folder, 'tm', target.origname)
718
+
719
+ for (const found of findFeatureSources(fs, overlay, [feature.name])) {
720
+ const from = Path.join(overlay, found.path)
721
+
722
+ // A folder source is the whole feature directory; expand it, because the
723
+ // comparison is per file.
724
+ if (found.folder) {
725
+ for (const rel of walk(fs, from)) {
726
+ out.set(found.path + '/' + rel, Path.join(from, rel))
727
+ }
728
+ }
729
+ else {
730
+ out.set(found.path, from)
731
+ }
732
+ }
733
+
734
+ return out
735
+ }
736
+
737
+
738
+ // A FEATURE's per-target source, checked from the feature's side.
739
+ //
740
+ // This is where those files are COMPARED. The target walk only marks them
741
+ // expected, so that each is compared exactly once — by the feature that owns
742
+ // it, which is also the only scope in which `feature add` will rewrite it.
743
+ //
744
+ // Doing it the other way round — leaving the comparison in the target walk —
745
+ // left the gate open in the case it was built for: `package update` on a
746
+ // FEATURE package scopes doctor to that feature, no target is walked, and so
747
+ // nothing compared the very overlay the feature package supplies. Verified
748
+ // before fixing: the update succeeded without asking, and the local edit was
749
+ // destroyed.
750
+ function checkFeatureSource(
751
+ actx: ActionContext,
752
+ feature: Source,
753
+ targets: Map<string, Source>,
754
+ report: DoctorReport,
755
+ ) {
355
756
  const fs = actx.fs()
757
+ const root = actx.folder
758
+ const model = actx.model
356
759
 
357
- const scaffold = Path.join(tfolder, 'model', 'target', torigname + '.aontu')
760
+ for (const [tname, target] of targets) {
761
+ for (const [rel, from] of overlayFiles(actx, feature, target)) {
762
+ const project = Path.join(root, 'tm', tname, rel)
763
+ const label = 'tm/' + tname + '/' + rel
764
+
765
+ if (!fs.existsSync(project)) {
766
+ report.missing.push(label)
767
+ continue
768
+ }
769
+
770
+ // The same map `feature add` copies with — see helpers/stdrep. A
771
+ // different one here would report every substituted file as edited.
772
+ if (differs(fs, from, project, model, templateReplacements(model, tname))) {
773
+ report.edited.push(label)
774
+ }
775
+ }
776
+ }
777
+ }
358
778
 
359
- // No scaffold file means nothing to compare against, which is the ALIASED
360
- // target: `target add go~go2` copies go.aontu to model/target/go2.aontu and
361
- // docs/how-to/add-a-target.md then tells the project to EDIT it (a second Go
362
- // module needs its own module name and deps). doctor resolves a target by
363
- // its INSTALLED name — the model records `base`, not the ref it came from —
364
- // so the origin is not recoverable here, and reporting that documented edit
365
- // as drift on every run is worse than saying nothing.
779
+
780
+ // The copied MODEL FILE `model/<kind>/<name>.aontu`, written by add with
781
+ // the `'BASE'` replacement, and overwritten on every resync exactly as
782
+ // `src/cmp` and `tm` are.
783
+ //
784
+ // It is not scaffolding trivia. A target's carries its dependency set, its
785
+ // `phase` gates, `srcfeature`, `feature.trim` / `feature.fullset` and its
786
+ // publish-registry identity; a feature's carries its version, `active`
787
+ // default, `config` defaults and hook wiring. So a maintainer who fixes a dep
788
+ // version there loses the fix on the next resync with nothing said (the SDK
789
+ // regresses with nobody touching it, which is the failure the "a project
790
+ // decision belongs in the MODEL" rule exists to prevent), and a project whose
791
+ // copy predates a scaffold change carries the old declaration forever while
792
+ // doctor reports it in sync.
793
+ //
794
+ // KIND-NEUTRAL, because the file is the same kind of thing for every kind:
795
+ // one definition, copied, stamped with provenance, optionally renamed for an
796
+ // alias. Only the alias rewrite is kind-specific, and the registry already
797
+ // says which kinds can be aliased at all.
798
+ function checkItemModel(
799
+ actx: ActionContext, kind: string, source: Source, report: DoctorReport,
800
+ ) {
801
+ const name = source.name
802
+ const origname = source.origname
803
+ const base = source.base
804
+ const fs = actx.fs()
805
+
806
+ const scaffold = definitionPath(source.folder, kind, origname)
807
+
808
+ // Nothing to compare against — a source that no longer ships this item.
366
809
  if (!fs.existsSync(scaffold)) {
367
810
  return
368
811
  }
369
812
 
370
- const project = Path.join(actx.folder, 'model', 'target', tname + '.aontu')
371
- const label = 'model/target/' + tname + '.aontu'
813
+ // An ALIAS's model file is PROJECT-OWNED: `target add go~go2` creates it and
814
+ // never overwrites it again, because differentiating it is the whole point
815
+ // of an alias (a second Go module needs its own module name and deps), and
816
+ // add-a-target tells the project to edit it. So a difference here is not a
817
+ // fork — `target add` will not revert it — and must not fail the check.
818
+ //
819
+ // It is still worth REPORTING, which it never was before: the origin was
820
+ // unrecoverable, so doctor skipped the file entirely and an alias that had
821
+ // drifted far from a moved-on upstream said nothing. With `origname`
822
+ // recorded the comparison is possible, so it runs, with the same key
823
+ // rewrite the add applied — and anything left over is the project's own
824
+ // differentiation, reported as informational.
825
+ //
826
+ // Only for a kind that CAN be aliased. A feature cannot, so for features
827
+ // this is always false and the ownership argument never applies — the
828
+ // registry says which, rather than this inferring it from the names
829
+ // happening to match.
830
+ const aliased = kindDef(kind).alias && name !== origname
831
+
832
+ const project = definitionPath(actx.folder, kind, name)
833
+ const label = 'model/' + kind + '/' + name + '.aontu'
372
834
 
373
835
  if (!fs.existsSync(project)) {
374
836
  report.missing.push(label)
375
837
  return
376
838
  }
377
839
 
378
- // The substitution `target add` applied on the way in: jostraca's template()
840
+ // The substitution the add applied on the way in: jostraca's template()
379
841
  // against the model (so `module: name: '$$name$$'` arrives as the project
380
- // slug) plus the `'BASE'` replacement recording where the scaffold was.
842
+ // slug) plus the provenance block recording where the scaffold was.
381
843
  //
382
844
  // NOT templateReplacements() — that is the tm/ tree's map. The two writers
383
- // pass different maps, so doctor has to as well, or every csharp/swift/ts
384
- // project reads as forked on the `base:` line alone.
385
- if (differs(fs, scaffold, project, actx.model, { "'BASE'": "'" + base + "'" })) {
386
- report.forked.push(label)
845
+ // pass different maps, so doctor has to as well, or every project reads as
846
+ // forked on the `base:` line alone. The map itself is shared with the
847
+ // writer (helpers/stdrep) so the two cannot drift.
848
+ //
849
+ // `package` comes from the SOURCE's manifest, not from what the copy
850
+ // records, for the same reason `base` does: what this compares is what the
851
+ // add would write NOW. A package that renamed itself therefore reads as
852
+ // forked, which is accurate — the next add would rewrite the line.
853
+ const provenance = provenanceReplace(
854
+ { base, origname, name, package: source.package })
855
+
856
+ // For an alias, compare against what the origin WOULD produce under the new
857
+ // name, so the rename itself is not the difference.
858
+ const rename = kindDef(kind).rename
859
+
860
+ const rewrite = (aliased && null != rename) ?
861
+ (src: string) => rename(src, origname, name) : undefined
862
+
863
+ if (!differs(fs, scaffold, project, actx.model, provenance, undefined, rewrite)) {
864
+ return
865
+ }
866
+
867
+ if (aliased) {
868
+ report.aliasedDiff.push(label)
869
+ return
870
+ }
871
+
872
+ // A copy written before the toolchain stamped some provenance key differs
873
+ // from the scaffold by exactly the lines carrying that key. That is not a
874
+ // fork — the project changed nothing — and reporting it as one would turn
875
+ // every existing consumer's CI red on upgrade.
876
+ //
877
+ // PER KEY, not all-or-nothing. The keys arrived in stages — `base` and
878
+ // `origname` first, `package` with the manifest — so a project that
879
+ // resynced between two of them holds a copy that IS stamped (it has `base`)
880
+ // and is still missing a later key. An all-or-nothing "is it stamped at
881
+ // all" test called every one of those a fork: on the released 3.4.8
882
+ // scaffold only ts, csharp and swift carried the anchor, and `ts` is in
883
+ // essentially every consumer SDK, so that was close to the whole installed
884
+ // base going red on a file nobody touched.
885
+ if (stampOnly(fs, scaffold, project, actx.model, provenance, rewrite)) {
886
+ report.resyncPending.push(label)
887
+ return
888
+ }
889
+
890
+ report.forked.push(label)
891
+ }
892
+
893
+
894
+ // Is the ONLY difference a provenance line the copy has not been given yet?
895
+ //
896
+ // MATCHED AGAINST THE EXACT LINES THE STAMP PRODUCES, never against a pattern
897
+ // for "a line that looks like provenance". `base`, `origname` and `package`
898
+ // are not reserved words: `main: kit: target: <t>: module: package` (the Go
899
+ // root package identifier) and `publish: registry: package` (the published
900
+ // package name) are declared model slots that a target model may write in
901
+ // block form, on their own line, looking exactly like a provenance line to
902
+ // any regex. A generic pattern got BOTH directions wrong on such a model —
903
+ // it read the copy's `module: package:` as proof the copy was already
904
+ // stamped, so an untouched pre-manifest file was reported as forked; and it
905
+ // stripped that same real line from the comparison, so DELETING it — an
906
+ // actual fork, silently reverted by the next `target add` — was reported as
907
+ // a pending resync and passed the check.
908
+ //
909
+ // Comparing against `provenanceReplace`'s own output has no such ambiguity,
910
+ // and keeps the reader tied to the writer, which is the whole point of
911
+ // helpers/stdrep.
912
+ //
913
+ // The rule, once the two are known to differ:
914
+ //
915
+ // - every line the EXPECTED has and the copy lacks must be a stamp line;
916
+ // - the copy must have NO line the expected lacks.
917
+ //
918
+ // The second half is what keeps the tolerance narrow, as the previous
919
+ // rollout's review required: a changed `package:` value leaves the old line
920
+ // in the copy and unmatched, so it is a fork and is reported as one.
921
+ function stampOnly(
922
+ fs: any, scaffoldPath: string, projectPath: string, model: any,
923
+ provenance: Record<string, string>,
924
+ rewrite?: (src: string) => string,
925
+ ): boolean {
926
+ const { expected, actual } = renderPair(
927
+ fs, scaffoldPath, projectPath, model, provenance, rewrite)
928
+
929
+ // The rendered block, as lines: `base: '...'` plus whichever of
930
+ // `origname:` / `package:` applied. Trimmed on both sides of the
931
+ // comparison, because the anchor's own indentation belongs to the scaffold.
932
+ const stamp = new Set(
933
+ Object.values(provenance).join('\n').split('\n')
934
+ .map((s: string) => s.trim()))
935
+
936
+ const onlyExpected = lineDiff(expected, actual)
937
+ const onlyActual = lineDiff(actual, expected)
938
+
939
+ return 0 === onlyActual.length &&
940
+ 0 < onlyExpected.length &&
941
+ onlyExpected.every((line: string) => stamp.has(line.trim()))
942
+ }
943
+
944
+
945
+ // Lines of `a` that `b` does not have, counting duplicates.
946
+ function lineDiff(a: string, b: string): string[] {
947
+ const pool = new Map<string, number>()
948
+
949
+ for (const line of b.split('\n')) {
950
+ pool.set(line, (pool.get(line) ?? 0) + 1)
951
+ }
952
+
953
+ const out: string[] = []
954
+
955
+ for (const line of a.split('\n')) {
956
+ const n = pool.get(line) ?? 0
957
+ if (0 < n) {
958
+ pool.set(line, n - 1)
959
+ }
960
+ else {
961
+ out.push(line)
962
+ }
387
963
  }
964
+
965
+ return out
388
966
  }
389
967
 
390
968
 
@@ -400,7 +978,7 @@ function walk(fs: any, dir: string): string[] {
400
978
  const descend = (rel: string) => {
401
979
  const abs = '' === rel ? dir : Path.join(dir, rel)
402
980
  for (const entry of fs.readdirSync(abs).sort()) {
403
- if (IGNORED_RE.test(entry)) {
981
+ if (ignoredEntry(entry)) {
404
982
  continue
405
983
  }
406
984
  const entryrel = '' === rel ? entry : rel + '/' + entry
@@ -432,27 +1010,59 @@ function excluded(rel: string, excludes: RegExp[]): boolean {
432
1010
  // Compare a scaffold file with a project file, applying the SAME substitution
433
1011
  // `target add` applied on the way in. Without this the comparison reports
434
1012
  // every substituted placeholder as an edit.
1013
+ // `ignore` drops matching lines from BOTH sides before comparing, for a
1014
+ // difference that is known not to be a fork (see the provenance rollout in
1015
+ // checkTargetModel). It is deliberately a second, narrower question asked
1016
+ // only after a plain comparison has already found a difference — so a file
1017
+ // that matches exactly never depends on it.
435
1018
  function differs(
436
1019
  fs: any, scaffoldPath: string, projectPath: string, model: any, replace: any,
1020
+ ignore?: (line: string) => boolean,
1021
+ rewrite?: (src: string) => string,
437
1022
  ): boolean {
438
1023
  if (BINARY_RE.test(scaffoldPath)) {
439
1024
  return !fs.readFileSync(scaffoldPath).equals(fs.readFileSync(projectPath))
440
1025
  }
441
1026
 
442
- const src = fs.readFileSync(scaffoldPath, 'utf8')
1027
+ const { expected, actual } = renderPair(
1028
+ fs, scaffoldPath, projectPath, model, replace, rewrite)
1029
+
1030
+ if (null == ignore) {
1031
+ return expected !== actual
1032
+ }
1033
+
1034
+ const strip = (s: string) =>
1035
+ s.split('\n').filter((line: string) => !ignore(line)).join('\n')
1036
+
1037
+ return strip(expected) !== strip(actual)
1038
+ }
443
1039
 
444
- // template() runs even when there is nothing to REPLACE, because jostraca's
445
- // Copy always interpolates `$$ref$$` against the model as well — the replace
446
- // map is an extra, not the whole substitution. Skipping it for the src/cmp
447
- // tree (whose Copy passes no replace map) meant the three Config fragments
448
- // that carry `$$const.Name$$` / `$$main.kit.info.servers.0.url$$` arrived
449
- // substituted and compared as bytes: every project with ts, js or dart
450
- // reported `src/cmp/<t>/fragment/Config.fragment.<ext>` as FORKED straight
451
- // out of `target add`, which is precisely the noise this function exists to
452
- // remove. With no `$$` refs and no replace keys, template() is the identity.
453
- const expected = template(src, model, { replace })
454
1040
 
455
- return expected !== fs.readFileSync(projectPath, 'utf8')
1041
+ // What `target add` WOULD write, beside what the project actually has.
1042
+ //
1043
+ // One definition, shared by the byte comparison and the stamp-only tolerance,
1044
+ // so the two can never disagree about what the expected text is.
1045
+ //
1046
+ // template() runs even when there is nothing to REPLACE, because jostraca's
1047
+ // Copy always interpolates `$$ref$$` against the model as well — the replace
1048
+ // map is an extra, not the whole substitution. Skipping it for the src/cmp
1049
+ // tree (whose Copy passes no replace map) meant the three Config fragments
1050
+ // that carry `$$const.Name$$` / `$$main.kit.info.servers.0.url$$` arrived
1051
+ // substituted and compared as bytes: every project with ts, js or dart
1052
+ // reported `src/cmp/<t>/fragment/Config.fragment.<ext>` as FORKED straight
1053
+ // out of `target add`, which is precisely the noise this exists to remove.
1054
+ // With no `$$` refs and no replace keys, template() is the identity.
1055
+ function renderPair(
1056
+ fs: any, scaffoldPath: string, projectPath: string, model: any, replace: any,
1057
+ rewrite?: (src: string) => string,
1058
+ ): { expected: string, actual: string } {
1059
+ const rawsrc = fs.readFileSync(scaffoldPath, 'utf8')
1060
+ const src = null == rewrite ? rawsrc : rewrite(rawsrc)
1061
+
1062
+ return {
1063
+ expected: template(src, model, { replace }),
1064
+ actual: fs.readFileSync(projectPath, 'utf8'),
1065
+ }
456
1066
  }
457
1067
 
458
1068
 
@@ -467,6 +1077,7 @@ function quietLog(log: any): any {
467
1077
 
468
1078
  export type {
469
1079
  DoctorReport,
1080
+ DoctorScope,
470
1081
  }
471
1082
 
472
1083
  export {