@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
@@ -41,9 +41,25 @@ const types_1 = require("../types");
41
41
  const utility_1 = require("../utility");
42
42
  const stdrep_1 = require("../helpers/stdrep");
43
43
  const target_1 = require("./target");
44
+ // `aliasModelText` is no longer named here: the model-file comparison asks
45
+ // the registry for the kind's own `rename`, so a kind that renames
46
+ // differently is handled without this file knowing how.
47
+ const kind_1 = require("./kind");
48
+ const resolve_1 = require("./resolve");
49
+ const definition_1 = require("../helpers/definition");
50
+ const featureSource_1 = require("../helpers/featureSource");
51
+ const junk_1 = require("../helpers/junk");
44
52
  // jostraca's Copy walk skips these (IGNORED_RE in CopyOp) — editor backups and
45
53
  // deliberately-disabled templates never reach a project, so they are not drift.
54
+ //
55
+ // `isJunk` is the other half of the same statement: what the WRITER refuses to
56
+ // copy, this reader must refuse to miss. A `__pycache__` in the scaffold would
57
+ // otherwise be reported as missing from every project on earth, and one in a
58
+ // project as an unexplained extra file. See helpers/junk.
46
59
  const IGNORED_RE = /(~|-jostraca-off)$/;
60
+ function ignoredEntry(name) {
61
+ return IGNORED_RE.test(name) || (0, junk_1.isJunk)(name);
62
+ }
47
63
  // Extensions jostraca copies byte-for-byte. Comparing them as text would
48
64
  // report spurious differences, so they are compared by raw bytes.
49
65
  const BINARY_RE = /\.(png|jpg|jpeg|gif|ico|pdf|zip|gz|woff2?|ttf|eot|wasm)$/i;
@@ -83,39 +99,85 @@ async function cmd_doctor_check(_args, actx) {
83
99
  }
84
100
  // Code API. Returns the report; the CLI turns a non-ok report into a
85
101
  // non-zero exit so this can gate CI.
86
- async function doctor(actx) {
102
+ async function doctor(actx, scope) {
87
103
  const log = actx.log;
88
104
  const fs = actx.fs();
89
105
  const model = actx.model;
90
106
  const root = actx.folder;
91
107
  const report = {
92
108
  forked: [], edited: [], stale: [], missing: [], additive: [],
93
- unwired: [], ok: true,
109
+ unwired: [], resyncPending: [], aliasedDiff: [], ok: true,
94
110
  };
95
- const targets = Object.keys(model?.main?.[types_1.KIT]?.target ?? {});
96
- log.info({ point: 'doctor-start', targets: targets.length });
97
- for (const tname of targets) {
98
- const declared = model?.main?.[types_1.KIT]?.target?.[tname];
99
- // The target model records where it came from (`base`, written by
100
- // `target add`). Fall back to the bundled scaffold.
101
- const tref = (declared && declared.base) ?
102
- node_path_1.default.join(declared.base, '..', tname) : tname;
103
- let resolved;
104
- try {
105
- resolved = (0, target_1.resolveTarget)(tref, { folder: root, fs: () => fs });
106
- }
107
- catch (err) {
108
- log.warn({
109
- point: 'doctor-target-unresolved', target: tname, err: err.message,
110
- note: tname + ': cannot find its scaffold (' + err.message + ')'
111
- });
112
- continue;
111
+ // EVERY KIND, not just targets.
112
+ //
113
+ // `add` writes a copied model file for each kind `model/target/<t>.aontu`
114
+ // and `model/feature/<f>.aontu` alike — and overwrites it on every resync.
115
+ // Only the target one was ever compared, so a hand-edit to an installed
116
+ // FEATURE definition read as perfectly in sync and was silently reverted by
117
+ // the next `target add` (which re-runs `feature add` for every active
118
+ // feature). That is exactly the failure `checkTargetModel` was added for,
119
+ // left open for the other half of what add owns.
120
+ //
121
+ // Driven off the registry, so a kind added later is checked without
122
+ // anything here changing. The per-kind EXTRAS — a target's `src/cmp` and
123
+ // `tm` trees — stay in that kind's own check, because they are genuinely
124
+ // different work rather than the same work with different strings.
125
+ const kinds = Object.keys(kind_1.KINDS).sort();
126
+ const counts = {};
127
+ for (const kind of kinds) {
128
+ counts[kind] = Object.keys(model?.main?.[types_1.KIT]?.[kind] ?? {}).length;
129
+ }
130
+ log.info({ point: 'doctor-start', targets: counts.target ?? 0, ...counts });
131
+ // Targets resolved ONCE, before the kind loop, because the feature checks
132
+ // need them too: a feature's per-target source lives under the name the
133
+ // target has in its own source, so finding it means knowing where each
134
+ // target came from. Resolving per (feature, target) pair would re-read
135
+ // every package manifest dozens of times and warn repeatedly about the
136
+ // same unresolvable target.
137
+ //
138
+ // Resolved even when the run is SCOPED past them — a scoped check of one
139
+ // feature still has to look in every target's tree for that feature's
140
+ // files.
141
+ const targets = new Map();
142
+ for (const tname of Object.keys(model?.main?.[types_1.KIT]?.target ?? {}).sort()) {
143
+ const source = resolveDeclared('target', tname, actx);
144
+ if (null != source) {
145
+ targets.set(tname, source);
113
146
  }
114
- if (null != resolved) {
115
- checkTarget(actx, resolved, report);
147
+ }
148
+ for (const kind of kinds) {
149
+ const items = Object.keys(model?.main?.[types_1.KIT]?.[kind] ?? {}).sort();
150
+ for (const name of items) {
151
+ if (null != scope && !scope(kind, name)) {
152
+ continue;
153
+ }
154
+ const source = 'target' === kind ?
155
+ targets.get(name) : resolveDeclared(kind, name, actx);
156
+ if (null == source) {
157
+ continue;
158
+ }
159
+ if ('target' === kind) {
160
+ checkTarget(actx, source, report);
161
+ }
162
+ if ('docs' === kind) {
163
+ checkDocs(actx, source, report);
164
+ }
165
+ // Only an ACTIVE feature has source copied out; what an inactive one
166
+ // left behind is stale, and the target walk reports it as such.
167
+ if ('feature' === kind &&
168
+ false !== model?.main?.[types_1.KIT]?.feature?.[name]?.active) {
169
+ checkFeatureSource(actx, source, targets, report);
170
+ }
171
+ checkItemModel(actx, kind, source, report);
116
172
  }
117
173
  }
118
- checkWiring(actx, report);
174
+ // Root-component wiring is a property of the PROJECT, not of any item, so a
175
+ // scoped run has no business reporting on it — `package update` asking "is
176
+ // this package's copy clean" should not also say the project never wired in
177
+ // `ReadmeTop`.
178
+ if (null == scope) {
179
+ checkWiring(actx, report);
180
+ }
119
181
  report.ok = 0 === report.forked.length + report.edited.length +
120
182
  report.stale.length + report.missing.length;
121
183
  for (const [kind, note] of [
@@ -125,6 +187,8 @@ async function doctor(actx) {
125
187
  ['missing', 'MISSING (would be written by `target add`)'],
126
188
  ['additive', 'additive (project-owned, not drift)'],
127
189
  ['unwired', 'NOT WIRED IN (root capability this project is missing)'],
190
+ ['resyncPending', 'RESYNC PENDING (predates provenance; `target add` updates it)'],
191
+ ['aliasedDiff', 'aliased model differs from its origin (project-owned, not drift)'],
128
192
  ]) {
129
193
  for (const file of report[kind]) {
130
194
  log.info({ point: 'doctor-finding', kind, file, note: note + ': ' + file });
@@ -139,6 +203,8 @@ async function doctor(actx) {
139
203
  missing: report.missing.length,
140
204
  additive: report.additive.length,
141
205
  unwired: report.unwired.length,
206
+ resyncPending: report.resyncPending.length,
207
+ aliasedDiff: report.aliasedDiff.length,
142
208
  note: report.ok ?
143
209
  ('.sdk matches the scaffold (' + report.additive.length + ' additive)') :
144
210
  ('.sdk has drifted: ' + report.forked.length + ' forked, ' +
@@ -172,8 +238,45 @@ function checkWiring(actx, report) {
172
238
  }
173
239
  }
174
240
  }
241
+ // Where one declared item came from, or undefined with the reason said out
242
+ // loud.
243
+ //
244
+ // The model records `base` and `origname` (written by add), and `origname` is
245
+ // what makes an ALIAS checkable: the ref used to be rebuilt as
246
+ // `<base>/../<name>`, which names the INSTALLED item — so `target add ts~ts2`
247
+ // sent doctor looking for a `ts2` scaffold that does not exist, both trees
248
+ // walked empty, and every component read as `additive` while every template
249
+ // read as `stale` (a FAILING category). Real edits were undetectable and
250
+ // doctor went red on noise.
251
+ //
252
+ // A bare name falls back to the bundled scaffold, for a copy predating
253
+ // provenance.
254
+ function resolveDeclared(kind, name, actx) {
255
+ const declared = actx.model?.main?.[types_1.KIT]?.[kind]?.[name];
256
+ const ref = (0, kind_1.recordedRef)(declared, name) || name;
257
+ try {
258
+ // WITH THE LOG. Resolution reads the source's package manifest, and an
259
+ // unusable one is reported only through `ctx$.log`. Without it doctor
260
+ // silently lost the package name — and then reported every item from that
261
+ // package as FORKED, because the expected text no longer carries the
262
+ // `package:` line the copy has, with nothing anywhere saying why. That is
263
+ // accurate (the next add really would strip the line) but unactionable,
264
+ // which is the same defect as being wrong.
265
+ return (0, resolve_1.resolveSource)(ref, kind, { folder: actx.folder, fs: actx.fs, log: actx.log });
266
+ }
267
+ catch (err) {
268
+ actx.log.warn({
269
+ point: 'doctor-source-unresolved', kind, [kind]: name, err: err.message,
270
+ note: name + ': cannot find its ' + kind + ' source (' +
271
+ err.message + ')'
272
+ });
273
+ return undefined;
274
+ }
275
+ }
175
276
  function checkTarget(actx, resolved, report) {
176
- const { tname, tfolder, torigname } = resolved;
277
+ const tname = resolved.name;
278
+ const tfolder = resolved.folder;
279
+ const torigname = resolved.origname;
177
280
  const fs = actx.fs();
178
281
  const model = actx.model;
179
282
  const root = actx.folder;
@@ -182,12 +285,26 @@ function checkTarget(actx, resolved, report) {
182
285
  //
183
286
  // src/cmp — copied verbatim, so a byte compare is the truth.
184
287
  // tm — copied through jostraca's template(), with ProjectName.
288
+ // An ALIASED install renames every `<Cmp>_<origname>` component to
289
+ // `<Cmp>_<alias>` and rewrites the origin name inside the file (sibling
290
+ // imports, the __dirname-relative fragment path), because components are
291
+ // dispatched by the convention `cmp/<t>/Main_<t>`. doctor has to apply the
292
+ // same two transforms or it expects the ORIGIN names in the alias's folder
293
+ // and reports the entire tree as missing — 25 files for `go~go2`, none of
294
+ // them a real finding.
295
+ const aliased = tname !== torigname;
296
+ const renameCmp = aliased ?
297
+ (rel) => (0, target_1.aliasCmpName)(rel, torigname, tname) : undefined;
298
+ const rewriteCmp = aliased ?
299
+ (src) => (0, target_1.aliasCmpText)(src, torigname, tname) : undefined;
185
300
  const trees = [
186
301
  {
187
302
  project: node_path_1.default.join(root, 'src', 'cmp', tname),
188
303
  scaffold: node_path_1.default.join(tfolder, 'src', 'cmp', torigname),
189
304
  replace: {},
190
305
  kind: 'forked',
306
+ rename: renameCmp,
307
+ rewrite: rewriteCmp,
191
308
  },
192
309
  {
193
310
  project: node_path_1.default.join(root, 'tm', tname),
@@ -205,14 +322,71 @@ function checkTarget(actx, resolved, report) {
205
322
  'test',
206
323
  ...Object.keys(featuremodel).filter((n) => false !== featuremodel[n]?.active),
207
324
  ]));
208
- const excludes = (0, target_1.trimFeatures)({ log: quietLog(actx.log), fs: () => fs }, tfolder, torigname, tname, features);
325
+ // `folder` and `model` matter: the trim catalogue is resolved consumer-side
326
+ // (see featureCatalogue), so a doctor that withheld them would compute a
327
+ // different trim from the one `target add` applied and report correctly
328
+ // trimmed files as missing.
329
+ const excludes = (0, target_1.trimFeatures)({ log: quietLog(actx.log), fs: () => fs, folder: root, model }, tfolder, torigname, tname, features);
330
+ compareTrees(actx, report, trees, {
331
+ excludes,
332
+ // Only a TARGET has foreign feature source landing in its tree.
333
+ foreign: (kind) => 'edited' === kind ?
334
+ foreignFeatureSource(actx, resolved) : [],
335
+ });
336
+ }
337
+ // COMPARE A SET OF TREES against the sources they were copied from.
338
+ //
339
+ // Extracted from `checkTarget` unchanged, because the docs kind needs exactly
340
+ // this and a second copy of it is the defect this workstream keeps producing.
341
+ // What differs per kind stays in the caller: which trees, what the copy
342
+ // substituted, whether a trim excludes part of the source, and whether
343
+ // anything foreign is expected to land in the tree.
344
+ //
345
+ // This is the mechanism behind the rule in CLAUDE.md — anything an add
346
+ // writes, doctor must compare, or the next add silently reverts a project's
347
+ // edit. A kind that declares trees and is not walked here would break it.
348
+ function compareTrees(actx, report, trees, opts) {
349
+ const fs = actx.fs();
350
+ const model = actx.model;
351
+ const root = actx.folder;
352
+ const excludes = opts?.excludes ?? [];
209
353
  for (const tree of trees) {
210
354
  // Findings are reported at project-relative paths, the way a maintainer
211
355
  // would type them.
212
356
  const label = node_path_1.default.relative(root, tree.project).split(node_path_1.default.sep).join('/') + '/';
213
- const expected = 'edited' === tree.kind ?
357
+ const scaffoldFiles = 'edited' === tree.kind ?
214
358
  walk(fs, tree.scaffold).filter((rel) => !excluded(rel, excludes)) :
215
359
  walk(fs, tree.scaffold);
360
+ // Where each file the project should have COMES FROM: the name it lands
361
+ // under, mapped to the absolute path of the file it was copied from.
362
+ //
363
+ // An absolute path rather than a tree-relative one, because not every
364
+ // expected file comes from THIS tree — see the foreign-feature union
365
+ // below.
366
+ const landed = new Map(scaffoldFiles.map((rel) => [
367
+ null == tree.rename ? rel : tree.rename(rel),
368
+ node_path_1.default.join(tree.scaffold, rel),
369
+ ]));
370
+ // FOREIGN FEATURE SOURCE.
371
+ //
372
+ // A feature supplied by a different package than the target ships its
373
+ // per-target source in ITS OWN `tm/<target>/` overlay, and `feature add`
374
+ // copies that into the project. Compared against the target's scaffold
375
+ // alone, those files are present in the project and absent upstream — so
376
+ // every one of them was reported STALE, a FAILING category. Any project
377
+ // using an external feature had a red `doctor` and nothing wrong with it.
378
+ //
379
+ // They are not stale, they are expected — just expected from somewhere
380
+ // else. Adding them here rather than suppressing them keeps them
381
+ // CHECKED: a hand-edit to a foreign feature's source is still reported,
382
+ // which is what `package update`'s gate needs in order to cover
383
+ // everything the re-add writes.
384
+ const foreign = new Set();
385
+ for (const [rel, from] of (opts?.foreign?.(tree.kind) ?? [])) {
386
+ landed.set(rel, from);
387
+ foreign.add(rel);
388
+ }
389
+ const expected = Array.from(landed.keys()).sort();
216
390
  const actual = walk(fs, tree.project);
217
391
  const expectedSet = new Set(expected);
218
392
  const actualSet = new Set(actual);
@@ -221,7 +395,16 @@ function checkTarget(actx, resolved, report) {
221
395
  report.missing.push(label + rel);
222
396
  continue;
223
397
  }
224
- if (differs(fs, node_path_1.default.join(tree.scaffold, rel), node_path_1.default.join(tree.project, rel), model, tree.replace)) {
398
+ // A foreign feature's file is EXPECTED here (so it is not stale) but
399
+ // compared by `checkFeatureSource`, from the feature's side. Comparing
400
+ // it here too would report it twice on a full run — and, worse, would
401
+ // leave it uncompared on a run scoped to the feature alone, which is
402
+ // exactly when `feature add` is about to rewrite it.
403
+ if (foreign.has(rel)) {
404
+ continue;
405
+ }
406
+ const from = landed.get(rel);
407
+ if (differs(fs, from, node_path_1.default.join(tree.project, rel), model, tree.replace, undefined, tree.rewrite)) {
225
408
  report[tree.kind].push(label + rel);
226
409
  }
227
410
  }
@@ -232,7 +415,8 @@ function checkTarget(actx, resolved, report) {
232
415
  // A component the scaffold has NEVER shipped is the project's own —
233
416
  // the supported way to add a per-target component. Anything else under
234
417
  // a tree `target add` owns is stale output.
235
- const known = fs.existsSync(node_path_1.default.join(tree.scaffold, rel));
418
+ const known = fs.existsSync(node_path_1.default.join(tree.scaffold, rel)) ||
419
+ landed.has(rel);
236
420
  if ('forked' === tree.kind && !known) {
237
421
  report.additive.push(label + rel);
238
422
  }
@@ -241,52 +425,299 @@ function checkTarget(actx, resolved, report) {
241
425
  }
242
426
  }
243
427
  }
244
- checkTargetModel(actx, resolved, report);
245
428
  }
246
- // The THIRD thing `target add` writes for a target, and the one nothing
247
- // compared: `model/target/<t>.aontu` (src/action/target.ts, the Copy with the
248
- // `'BASE'` replacement).
429
+ // A DOCS item's trees.
430
+ //
431
+ // Simpler than a target's in every way that matters here: no trim (a docs
432
+ // item has no per-feature source to leave out), and nothing foreign lands in
433
+ // them (a feature package's overlay targets a TARGET's tree). What is the
434
+ // same is the alias handling — docs components are dispatched by the same
435
+ // `Main_<n>` convention — and the tree paths, which come from the registry
436
+ // rather than being spelled a second time.
249
437
  //
250
- // It is not scaffolding trivia. That file carries the target's dependency
251
- // set, its `phase` gates, `srcfeature`, `feature.trim` / `feature.fullset`
252
- // and its publish-registry identity — and `target add` OVERWRITES it exactly
253
- // as it overwrites src/cmp and tm. So a maintainer who fixes a dep version
254
- // there loses the fix on the next resync with nothing said (the SDK regresses
255
- // with nobody touching it, which is the failure the "a project decision
256
- // belongs in the MODEL" rule exists to prevent), and a project whose copy
257
- // predates a scaffold change carries the old declaration forever while doctor
258
- // reported the target in sync. Both are the drift this command exists to
259
- // name; the file was simply in neither tree it walked.
260
- function checkTargetModel(actx, resolved, report) {
261
- const { tname, tfolder, torigname, base } = resolved;
438
+ // The optional template tree is skipped when the SOURCE does not ship one:
439
+ // `docs add` did not copy it, so the project is right not to have it.
440
+ function checkDocs(actx, resolved, report) {
262
441
  const fs = actx.fs();
263
- const scaffold = node_path_1.default.join(tfolder, 'model', 'target', torigname + '.aontu');
264
- // No scaffold file means nothing to compare against, which is the ALIASED
265
- // target: `target add go~go2` copies go.aontu to model/target/go2.aontu and
266
- // docs/how-to/add-a-target.md then tells the project to EDIT it (a second Go
267
- // module needs its own module name and deps). doctor resolves a target by
268
- // its INSTALLED name the model records `base`, not the ref it came from —
269
- // so the origin is not recoverable here, and reporting that documented edit
270
- // as drift on every run is worse than saying nothing.
442
+ const root = actx.folder;
443
+ const name = resolved.name;
444
+ const origname = resolved.origname;
445
+ const aliased = name !== origname;
446
+ const dest = (0, kind_1.kindTrees)('docs', name);
447
+ const from = (0, kind_1.kindTrees)('docs', origname);
448
+ const trees = dest.flatMap((tree, i) => {
449
+ const scaffold = node_path_1.default.join(resolved.folder, ...from[i].path.split('/'));
450
+ if (!fs.existsSync(scaffold)) {
451
+ return [];
452
+ }
453
+ const templated = 'template' === tree.replace;
454
+ return [{
455
+ project: node_path_1.default.join(root, ...tree.path.split('/')),
456
+ scaffold,
457
+ replace: templated ? (0, stdrep_1.templateReplacements)(actx.model, name) : {},
458
+ kind: templated ? 'edited' : 'forked',
459
+ rename: aliased && !templated ?
460
+ (rel) => (0, target_1.aliasCmpName)(rel, origname, name) : undefined,
461
+ rewrite: aliased && !templated ?
462
+ (src) => (0, target_1.aliasCmpText)(src, origname, name) : undefined,
463
+ }];
464
+ });
465
+ compareTrees(actx, report, trees);
466
+ }
467
+ // Per-target source that a FEATURE package supplies for this target, as
468
+ // `project-relative path -> the file it was copied from`.
469
+ //
470
+ // Only for features whose source folder is not the target's own: everything
471
+ // the target ships for itself is already in the tree being walked, and
472
+ // listing it twice would compare it against itself.
473
+ //
474
+ // Only for ACTIVE features, because that is what `feature add` copies. A
475
+ // feature switched off later leaves its files behind, and those really are
476
+ // stale — reporting them is the point.
477
+ //
478
+ // Discovered with `findFeatureSources`, the same function the fan-out and the
479
+ // trim use, so doctor's idea of which files belong to a feature cannot drift
480
+ // from the one that put them there.
481
+ function foreignFeatureSource(actx, target) {
482
+ const out = new Map();
483
+ const features = actx.model?.main?.[types_1.KIT]?.feature ?? {};
484
+ for (const fname of Object.keys(features).sort()) {
485
+ if (false === features[fname]?.active) {
486
+ continue;
487
+ }
488
+ const source = resolveDeclared('feature', fname, actx);
489
+ if (null == source) {
490
+ continue;
491
+ }
492
+ for (const [rel, from] of overlayFiles(actx, source, target)) {
493
+ out.set(rel, from);
494
+ }
495
+ }
496
+ return out;
497
+ }
498
+ // One feature's per-target source, as `tm-relative path -> the file it was
499
+ // copied from`, for source the FEATURE package supplies rather than the
500
+ // target.
501
+ //
502
+ // ONE enumerator, used from both directions: the target walk needs these to
503
+ // know they are expected, and the feature check needs them to compare. Two
504
+ // enumerations would be two ideas of which files a feature owns, and the
505
+ // pair would drift — which is the failure this codebase keeps producing.
506
+ //
507
+ // Empty when the feature's source IS the target's own tree: those files are
508
+ // already in the tree being walked, and listing them would compare them
509
+ // against themselves.
510
+ function overlayFiles(actx, feature, target) {
511
+ const fs = actx.fs();
512
+ const out = new Map();
513
+ if (feature.folder === target.folder) {
514
+ return out;
515
+ }
516
+ // The feature package's overlay for THIS target, under the name the target
517
+ // has in its own source — an aliased target's templates live at
518
+ // `tm/<origname>`.
519
+ const overlay = node_path_1.default.join(feature.folder, 'tm', target.origname);
520
+ for (const found of (0, featureSource_1.findFeatureSources)(fs, overlay, [feature.name])) {
521
+ const from = node_path_1.default.join(overlay, found.path);
522
+ // A folder source is the whole feature directory; expand it, because the
523
+ // comparison is per file.
524
+ if (found.folder) {
525
+ for (const rel of walk(fs, from)) {
526
+ out.set(found.path + '/' + rel, node_path_1.default.join(from, rel));
527
+ }
528
+ }
529
+ else {
530
+ out.set(found.path, from);
531
+ }
532
+ }
533
+ return out;
534
+ }
535
+ // A FEATURE's per-target source, checked from the feature's side.
536
+ //
537
+ // This is where those files are COMPARED. The target walk only marks them
538
+ // expected, so that each is compared exactly once — by the feature that owns
539
+ // it, which is also the only scope in which `feature add` will rewrite it.
540
+ //
541
+ // Doing it the other way round — leaving the comparison in the target walk —
542
+ // left the gate open in the case it was built for: `package update` on a
543
+ // FEATURE package scopes doctor to that feature, no target is walked, and so
544
+ // nothing compared the very overlay the feature package supplies. Verified
545
+ // before fixing: the update succeeded without asking, and the local edit was
546
+ // destroyed.
547
+ function checkFeatureSource(actx, feature, targets, report) {
548
+ const fs = actx.fs();
549
+ const root = actx.folder;
550
+ const model = actx.model;
551
+ for (const [tname, target] of targets) {
552
+ for (const [rel, from] of overlayFiles(actx, feature, target)) {
553
+ const project = node_path_1.default.join(root, 'tm', tname, rel);
554
+ const label = 'tm/' + tname + '/' + rel;
555
+ if (!fs.existsSync(project)) {
556
+ report.missing.push(label);
557
+ continue;
558
+ }
559
+ // The same map `feature add` copies with — see helpers/stdrep. A
560
+ // different one here would report every substituted file as edited.
561
+ if (differs(fs, from, project, model, (0, stdrep_1.templateReplacements)(model, tname))) {
562
+ report.edited.push(label);
563
+ }
564
+ }
565
+ }
566
+ }
567
+ // The copied MODEL FILE — `model/<kind>/<name>.aontu`, written by add with
568
+ // the `'BASE'` replacement, and overwritten on every resync exactly as
569
+ // `src/cmp` and `tm` are.
570
+ //
571
+ // It is not scaffolding trivia. A target's carries its dependency set, its
572
+ // `phase` gates, `srcfeature`, `feature.trim` / `feature.fullset` and its
573
+ // publish-registry identity; a feature's carries its version, `active`
574
+ // default, `config` defaults and hook wiring. So a maintainer who fixes a dep
575
+ // version there loses the fix on the next resync with nothing said (the SDK
576
+ // regresses with nobody touching it, which is the failure the "a project
577
+ // decision belongs in the MODEL" rule exists to prevent), and a project whose
578
+ // copy predates a scaffold change carries the old declaration forever while
579
+ // doctor reports it in sync.
580
+ //
581
+ // KIND-NEUTRAL, because the file is the same kind of thing for every kind:
582
+ // one definition, copied, stamped with provenance, optionally renamed for an
583
+ // alias. Only the alias rewrite is kind-specific, and the registry already
584
+ // says which kinds can be aliased at all.
585
+ function checkItemModel(actx, kind, source, report) {
586
+ const name = source.name;
587
+ const origname = source.origname;
588
+ const base = source.base;
589
+ const fs = actx.fs();
590
+ const scaffold = (0, definition_1.definitionPath)(source.folder, kind, origname);
591
+ // Nothing to compare against — a source that no longer ships this item.
271
592
  if (!fs.existsSync(scaffold)) {
272
593
  return;
273
594
  }
274
- const project = node_path_1.default.join(actx.folder, 'model', 'target', tname + '.aontu');
275
- const label = 'model/target/' + tname + '.aontu';
595
+ // An ALIAS's model file is PROJECT-OWNED: `target add go~go2` creates it and
596
+ // never overwrites it again, because differentiating it is the whole point
597
+ // of an alias (a second Go module needs its own module name and deps), and
598
+ // add-a-target tells the project to edit it. So a difference here is not a
599
+ // fork — `target add` will not revert it — and must not fail the check.
600
+ //
601
+ // It is still worth REPORTING, which it never was before: the origin was
602
+ // unrecoverable, so doctor skipped the file entirely and an alias that had
603
+ // drifted far from a moved-on upstream said nothing. With `origname`
604
+ // recorded the comparison is possible, so it runs, with the same key
605
+ // rewrite the add applied — and anything left over is the project's own
606
+ // differentiation, reported as informational.
607
+ //
608
+ // Only for a kind that CAN be aliased. A feature cannot, so for features
609
+ // this is always false and the ownership argument never applies — the
610
+ // registry says which, rather than this inferring it from the names
611
+ // happening to match.
612
+ const aliased = (0, kind_1.kindDef)(kind).alias && name !== origname;
613
+ const project = (0, definition_1.definitionPath)(actx.folder, kind, name);
614
+ const label = 'model/' + kind + '/' + name + '.aontu';
276
615
  if (!fs.existsSync(project)) {
277
616
  report.missing.push(label);
278
617
  return;
279
618
  }
280
- // The substitution `target add` applied on the way in: jostraca's template()
619
+ // The substitution the add applied on the way in: jostraca's template()
281
620
  // against the model (so `module: name: '$$name$$'` arrives as the project
282
- // slug) plus the `'BASE'` replacement recording where the scaffold was.
621
+ // slug) plus the provenance block recording where the scaffold was.
283
622
  //
284
623
  // NOT templateReplacements() — that is the tm/ tree's map. The two writers
285
- // pass different maps, so doctor has to as well, or every csharp/swift/ts
286
- // project reads as forked on the `base:` line alone.
287
- if (differs(fs, scaffold, project, actx.model, { "'BASE'": "'" + base + "'" })) {
288
- report.forked.push(label);
624
+ // pass different maps, so doctor has to as well, or every project reads as
625
+ // forked on the `base:` line alone. The map itself is shared with the
626
+ // writer (helpers/stdrep) so the two cannot drift.
627
+ //
628
+ // `package` comes from the SOURCE's manifest, not from what the copy
629
+ // records, for the same reason `base` does: what this compares is what the
630
+ // add would write NOW. A package that renamed itself therefore reads as
631
+ // forked, which is accurate — the next add would rewrite the line.
632
+ const provenance = (0, stdrep_1.provenanceReplace)({ base, origname, name, package: source.package });
633
+ // For an alias, compare against what the origin WOULD produce under the new
634
+ // name, so the rename itself is not the difference.
635
+ const rename = (0, kind_1.kindDef)(kind).rename;
636
+ const rewrite = (aliased && null != rename) ?
637
+ (src) => rename(src, origname, name) : undefined;
638
+ if (!differs(fs, scaffold, project, actx.model, provenance, undefined, rewrite)) {
639
+ return;
640
+ }
641
+ if (aliased) {
642
+ report.aliasedDiff.push(label);
643
+ return;
644
+ }
645
+ // A copy written before the toolchain stamped some provenance key differs
646
+ // from the scaffold by exactly the lines carrying that key. That is not a
647
+ // fork — the project changed nothing — and reporting it as one would turn
648
+ // every existing consumer's CI red on upgrade.
649
+ //
650
+ // PER KEY, not all-or-nothing. The keys arrived in stages — `base` and
651
+ // `origname` first, `package` with the manifest — so a project that
652
+ // resynced between two of them holds a copy that IS stamped (it has `base`)
653
+ // and is still missing a later key. An all-or-nothing "is it stamped at
654
+ // all" test called every one of those a fork: on the released 3.4.8
655
+ // scaffold only ts, csharp and swift carried the anchor, and `ts` is in
656
+ // essentially every consumer SDK, so that was close to the whole installed
657
+ // base going red on a file nobody touched.
658
+ if (stampOnly(fs, scaffold, project, actx.model, provenance, rewrite)) {
659
+ report.resyncPending.push(label);
660
+ return;
661
+ }
662
+ report.forked.push(label);
663
+ }
664
+ // Is the ONLY difference a provenance line the copy has not been given yet?
665
+ //
666
+ // MATCHED AGAINST THE EXACT LINES THE STAMP PRODUCES, never against a pattern
667
+ // for "a line that looks like provenance". `base`, `origname` and `package`
668
+ // are not reserved words: `main: kit: target: <t>: module: package` (the Go
669
+ // root package identifier) and `publish: registry: package` (the published
670
+ // package name) are declared model slots that a target model may write in
671
+ // block form, on their own line, looking exactly like a provenance line to
672
+ // any regex. A generic pattern got BOTH directions wrong on such a model —
673
+ // it read the copy's `module: package:` as proof the copy was already
674
+ // stamped, so an untouched pre-manifest file was reported as forked; and it
675
+ // stripped that same real line from the comparison, so DELETING it — an
676
+ // actual fork, silently reverted by the next `target add` — was reported as
677
+ // a pending resync and passed the check.
678
+ //
679
+ // Comparing against `provenanceReplace`'s own output has no such ambiguity,
680
+ // and keeps the reader tied to the writer, which is the whole point of
681
+ // helpers/stdrep.
682
+ //
683
+ // The rule, once the two are known to differ:
684
+ //
685
+ // - every line the EXPECTED has and the copy lacks must be a stamp line;
686
+ // - the copy must have NO line the expected lacks.
687
+ //
688
+ // The second half is what keeps the tolerance narrow, as the previous
689
+ // rollout's review required: a changed `package:` value leaves the old line
690
+ // in the copy and unmatched, so it is a fork and is reported as one.
691
+ function stampOnly(fs, scaffoldPath, projectPath, model, provenance, rewrite) {
692
+ const { expected, actual } = renderPair(fs, scaffoldPath, projectPath, model, provenance, rewrite);
693
+ // The rendered block, as lines: `base: '...'` plus whichever of
694
+ // `origname:` / `package:` applied. Trimmed on both sides of the
695
+ // comparison, because the anchor's own indentation belongs to the scaffold.
696
+ const stamp = new Set(Object.values(provenance).join('\n').split('\n')
697
+ .map((s) => s.trim()));
698
+ const onlyExpected = lineDiff(expected, actual);
699
+ const onlyActual = lineDiff(actual, expected);
700
+ return 0 === onlyActual.length &&
701
+ 0 < onlyExpected.length &&
702
+ onlyExpected.every((line) => stamp.has(line.trim()));
703
+ }
704
+ // Lines of `a` that `b` does not have, counting duplicates.
705
+ function lineDiff(a, b) {
706
+ const pool = new Map();
707
+ for (const line of b.split('\n')) {
708
+ pool.set(line, (pool.get(line) ?? 0) + 1);
709
+ }
710
+ const out = [];
711
+ for (const line of a.split('\n')) {
712
+ const n = pool.get(line) ?? 0;
713
+ if (0 < n) {
714
+ pool.set(line, n - 1);
715
+ }
716
+ else {
717
+ out.push(line);
718
+ }
289
719
  }
720
+ return out;
290
721
  }
291
722
  // Every file under `dir`, as forward-slash paths relative to it, sorted.
292
723
  // Missing directory -> no files (a target that was never added).
@@ -298,7 +729,7 @@ function walk(fs, dir) {
298
729
  const descend = (rel) => {
299
730
  const abs = '' === rel ? dir : node_path_1.default.join(dir, rel);
300
731
  for (const entry of fs.readdirSync(abs).sort()) {
301
- if (IGNORED_RE.test(entry)) {
732
+ if (ignoredEntry(entry)) {
302
733
  continue;
303
734
  }
304
735
  const entryrel = '' === rel ? entry : rel + '/' + entry;
@@ -324,22 +755,43 @@ function excluded(rel, excludes) {
324
755
  // Compare a scaffold file with a project file, applying the SAME substitution
325
756
  // `target add` applied on the way in. Without this the comparison reports
326
757
  // every substituted placeholder as an edit.
327
- function differs(fs, scaffoldPath, projectPath, model, replace) {
758
+ // `ignore` drops matching lines from BOTH sides before comparing, for a
759
+ // difference that is known not to be a fork (see the provenance rollout in
760
+ // checkTargetModel). It is deliberately a second, narrower question asked
761
+ // only after a plain comparison has already found a difference — so a file
762
+ // that matches exactly never depends on it.
763
+ function differs(fs, scaffoldPath, projectPath, model, replace, ignore, rewrite) {
328
764
  if (BINARY_RE.test(scaffoldPath)) {
329
765
  return !fs.readFileSync(scaffoldPath).equals(fs.readFileSync(projectPath));
330
766
  }
331
- const src = fs.readFileSync(scaffoldPath, 'utf8');
332
- // template() runs even when there is nothing to REPLACE, because jostraca's
333
- // Copy always interpolates `$$ref$$` against the model as well — the replace
334
- // map is an extra, not the whole substitution. Skipping it for the src/cmp
335
- // tree (whose Copy passes no replace map) meant the three Config fragments
336
- // that carry `$$const.Name$$` / `$$main.kit.info.servers.0.url$$` arrived
337
- // substituted and compared as bytes: every project with ts, js or dart
338
- // reported `src/cmp/<t>/fragment/Config.fragment.<ext>` as FORKED straight
339
- // out of `target add`, which is precisely the noise this function exists to
340
- // remove. With no `$$` refs and no replace keys, template() is the identity.
341
- const expected = (0, jostraca_1.template)(src, model, { replace });
342
- return expected !== fs.readFileSync(projectPath, 'utf8');
767
+ const { expected, actual } = renderPair(fs, scaffoldPath, projectPath, model, replace, rewrite);
768
+ if (null == ignore) {
769
+ return expected !== actual;
770
+ }
771
+ const strip = (s) => s.split('\n').filter((line) => !ignore(line)).join('\n');
772
+ return strip(expected) !== strip(actual);
773
+ }
774
+ // What `target add` WOULD write, beside what the project actually has.
775
+ //
776
+ // One definition, shared by the byte comparison and the stamp-only tolerance,
777
+ // so the two can never disagree about what the expected text is.
778
+ //
779
+ // template() runs even when there is nothing to REPLACE, because jostraca's
780
+ // Copy always interpolates `$$ref$$` against the model as well — the replace
781
+ // map is an extra, not the whole substitution. Skipping it for the src/cmp
782
+ // tree (whose Copy passes no replace map) meant the three Config fragments
783
+ // that carry `$$const.Name$$` / `$$main.kit.info.servers.0.url$$` arrived
784
+ // substituted and compared as bytes: every project with ts, js or dart
785
+ // reported `src/cmp/<t>/fragment/Config.fragment.<ext>` as FORKED straight
786
+ // out of `target add`, which is precisely the noise this exists to remove.
787
+ // With no `$$` refs and no replace keys, template() is the identity.
788
+ function renderPair(fs, scaffoldPath, projectPath, model, replace, rewrite) {
789
+ const rawsrc = fs.readFileSync(scaffoldPath, 'utf8');
790
+ const src = null == rewrite ? rawsrc : rewrite(rawsrc);
791
+ return {
792
+ expected: (0, jostraca_1.template)(src, model, { replace }),
793
+ actual: fs.readFileSync(projectPath, 'utf8'),
794
+ };
343
795
  }
344
796
  // trimFeatures logs its decisions; doctor is a report, not a run.
345
797
  function quietLog(log) {