@voxgig/sdkgen 4.33.0 → 4.34.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.
@@ -0,0 +1,219 @@
1
+ import Path from 'node:path'
2
+
3
+ import { createHash } from 'node:crypto'
4
+
5
+ import type { ActionContext } from '../types'
6
+
7
+ import {
8
+ definitionFileName, definitionFolder, indexName,
9
+ } from '../helpers/definition'
10
+
11
+ import { KINDS } from './kind'
12
+
13
+ import type { Source } from './resolve'
14
+
15
+
16
+ // What every add last wrote into `.sdk`, by fingerprint, and the version of
17
+ // the package each item came from. It is the only record that tells a copy
18
+ // the project changed from one its source has since moved past.
19
+ const COPIES = 'sdkgen-copies.json'
20
+
21
+ const ABOUT = 'Written by voxgig-sdkgen on every add: what it copied into ' +
22
+ '.sdk, so that doctor, generate and package update can tell an outdated ' +
23
+ 'copy from a local edit. Commit it; do not edit it.'
24
+
25
+
26
+ type CopyItem = {
27
+ package?: string
28
+ version?: string
29
+ }
30
+
31
+ type CopyRecord = {
32
+ items: Record<string, CopyItem>
33
+ files: Record<string, string>
34
+ }
35
+
36
+
37
+ function copiesPath(folder: string): string {
38
+ return Path.join(folder, COPIES)
39
+ }
40
+
41
+
42
+ function itemKey(kind: string, name: string): string {
43
+ return kind + '/' + name
44
+ }
45
+
46
+
47
+ // CRLF and LF copies of one file share a fingerprint, as doctor compares
48
+ // them. latin1 maps each byte to one character, so nothing else changes.
49
+ function fingerprint(content: any): string {
50
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content))
51
+ const lf = Buffer.from(bytes.toString('latin1').replace(/\r\n/g, '\n'), 'latin1')
52
+ return createHash('sha256').update(lf).digest('hex').slice(0, 16)
53
+ }
54
+
55
+
56
+ // An unreadable record is treated as absent: every copy is then unrecorded,
57
+ // which is exactly the state of a project from before the record existed.
58
+ function readCopies(fs: any, folder: string): CopyRecord {
59
+ const empty: CopyRecord = { items: {}, files: {} }
60
+ const path = copiesPath(folder)
61
+
62
+ if (!fs.existsSync(path)) {
63
+ return empty
64
+ }
65
+
66
+ try {
67
+ const read = JSON.parse(String(fs.readFileSync(path, 'utf8')))
68
+ return {
69
+ items: { ...(read?.items ?? {}) },
70
+ files: { ...(read?.files ?? {}) },
71
+ }
72
+ }
73
+ catch (err: any) {
74
+ return empty
75
+ }
76
+ }
77
+
78
+
79
+ function writeCopies(fs: any, folder: string, record: CopyRecord) {
80
+ const sorted = (obj: Record<string, any>) => Object.fromEntries(
81
+ Object.keys(obj).sort().map((key: string) => [key, obj[key]]))
82
+
83
+ fs.writeFileSync(copiesPath(folder), JSON.stringify({
84
+ about: ABOUT,
85
+ items: sorted(record.items),
86
+ files: sorted(record.files),
87
+ }, null, 2) + '\n')
88
+ }
89
+
90
+
91
+ function provenanceOf(source: Source): CopyItem {
92
+ return {
93
+ ...(null == source.package ? {} : { package: source.package }),
94
+ ...(null == source.version ? {} : { version: source.version }),
95
+ }
96
+ }
97
+
98
+
99
+ // The trees and definition files an add owns. An index is excluded: every
100
+ // add rewrites it and no source holds a copy to compare it with.
101
+ const TREE_ROOTS = Array.from(new Set(Object.values(KINDS)
102
+ .flatMap((kind: any) => (kind.trees ?? [])
103
+ .map((tree: any) => String(tree.path).split('{name}')[0]))))
104
+
105
+ function isCopy(rel: string): boolean {
106
+ if (TREE_ROOTS.some((root: string) => rel.startsWith(root))) {
107
+ return true
108
+ }
109
+
110
+ const dir = Path.posix.dirname(rel)
111
+ const base = Path.posix.basename(rel)
112
+
113
+ return Object.keys(KINDS).some((kind: string) =>
114
+ dir === definitionFolder('', kind).split(Path.sep).join('/') &&
115
+ base !== indexName(kind) &&
116
+ base.endsWith(definitionFileName('')))
117
+ }
118
+
119
+
120
+ function relativeTo(folder: string, abs: string): string | undefined {
121
+ const rel = Path.relative(Path.resolve(folder), Path.resolve(abs))
122
+ .split(Path.sep).join('/')
123
+
124
+ return ('' === rel || rel.startsWith('../') || Path.isAbsolute(rel)) ?
125
+ undefined : rel
126
+ }
127
+
128
+
129
+ // Called by each add with what its own jostraca run wrote. A dry run writes
130
+ // nothing, so it records nothing.
131
+ function recordCopies(
132
+ actx: ActionContext, jres: any, kind: string, sources: Source[],
133
+ ) {
134
+ if (true === actx.opts?.dryrun || null == jres?.files) {
135
+ return
136
+ }
137
+
138
+ const fs = actx.fs()
139
+ const folder = actx.folder
140
+ const record = readCopies(fs, folder)
141
+
142
+ const files: string[] = [
143
+ ...(jres.files.written ?? []), ...(jres.files.unchanged ?? []),
144
+ ]
145
+
146
+ for (const abs of files) {
147
+ const rel = relativeTo(folder, abs)
148
+
149
+ if (null != rel && isCopy(rel) && fs.existsSync(abs)) {
150
+ record.files[rel] = fingerprint(fs.readFileSync(abs))
151
+ }
152
+ }
153
+
154
+ for (const source of sources) {
155
+ record.items[itemKey(kind, source.name)] = provenanceOf(source)
156
+ }
157
+
158
+ pruneMissing(fs, folder, record)
159
+ writeCopies(fs, folder, record)
160
+ }
161
+
162
+
163
+ // After a remove: the item goes, with every file entry nothing holds now.
164
+ function forgetCopies(actx: ActionContext, kind: string, name: string) {
165
+ const fs = actx.fs()
166
+ const folder = actx.folder
167
+
168
+ if (true === actx.opts?.dryrun || !fs.existsSync(copiesPath(folder))) {
169
+ return
170
+ }
171
+
172
+ const record = readCopies(fs, folder)
173
+ delete record.items[itemKey(kind, name)]
174
+ pruneMissing(fs, folder, record)
175
+ writeCopies(fs, folder, record)
176
+ }
177
+
178
+
179
+ function pruneMissing(fs: any, folder: string, record: CopyRecord) {
180
+ for (const rel of Object.keys(record.files)) {
181
+ if (!fs.existsSync(Path.join(folder, ...rel.split('/')))) {
182
+ delete record.files[rel]
183
+ }
184
+ }
185
+ }
186
+
187
+
188
+ // Was this project file left exactly as an add wrote it?
189
+ function untouched(
190
+ fs: any, folder: string, record: CopyRecord, rel: string,
191
+ ): boolean | undefined {
192
+ const recorded = record.files[rel]
193
+
194
+ if (null == recorded) {
195
+ return undefined
196
+ }
197
+
198
+ const abs = Path.join(folder, ...rel.split('/'))
199
+
200
+ return fs.existsSync(abs) && recorded === fingerprint(fs.readFileSync(abs))
201
+ }
202
+
203
+
204
+ export type {
205
+ CopyItem,
206
+ CopyRecord,
207
+ }
208
+
209
+ export {
210
+ COPIES,
211
+ copiesPath,
212
+ itemKey,
213
+ fingerprint,
214
+ readCopies,
215
+ recordCopies,
216
+ forgetCopies,
217
+ untouched,
218
+ isCopy,
219
+ }
@@ -37,6 +37,9 @@ import { findFeatureSources } from '../helpers/featureSource'
37
37
 
38
38
  import { isJunk } from '../helpers/junk'
39
39
 
40
+ import { readCopies, untouched, itemKey } from './copies'
41
+ import type { CopyRecord } from './copies'
42
+
40
43
 
41
44
  const IGNORED_RE = /(~|-jostraca-off)$/
42
45
 
@@ -97,10 +100,41 @@ type DoctorReport = {
97
100
  // it is what an alias is FOR.
98
101
  aliasedDiff: string[]
99
102
 
103
+ // Differs from its source, but the copy record shows it untouched since an
104
+ // add wrote it: the source moved on, so refreshing it discards nothing.
105
+ outdated: string[]
106
+
107
+ // The forked and edited findings the copy record has no entry for, so
108
+ // nothing shows whether the project changed them.
109
+ unrecorded: string[]
110
+
111
+ // Per item with findings, keyed `<kind>/<name>`.
112
+ byItem: Record<string, ItemDrift>
113
+
100
114
  ok: boolean
101
115
  }
102
116
 
103
117
 
118
+ type ItemDrift = {
119
+ // The package the item resolves to now, which is what refreshes it.
120
+ package?: string
121
+
122
+ // The version its copies came from, and the version installed now.
123
+ from?: string
124
+ to?: string
125
+
126
+ outdated: number
127
+
128
+ // Forked, edited, missing and stale files.
129
+ changed: number
130
+ }
131
+
132
+
133
+ type Category = 'forked' | 'edited' | 'missing' | 'stale'
134
+
135
+ type Found = (category: Category, label: string) => void
136
+
137
+
104
138
  const CMD_MAP: any = {
105
139
  check: cmd_doctor_check,
106
140
  prune: cmd_doctor_prune,
@@ -243,9 +277,12 @@ async function doctor(
243
277
  const report: DoctorReport = {
244
278
  forked: [], edited: [], stale: [], missing: [], additive: [],
245
279
  superseded: [], unwired: [], orphanModel: [],
246
- resyncPending: [], aliasedDiff: [], ok: true,
280
+ resyncPending: [], aliasedDiff: [],
281
+ outdated: [], unrecorded: [], byItem: {}, ok: true,
247
282
  }
248
283
 
284
+ const copies = readCopies(fs, root)
285
+
249
286
  report.superseded = supersededFiles(actx)
250
287
 
251
288
  report.orphanModel = orphanModelFiles(actx)
@@ -285,12 +322,14 @@ async function doctor(
285
322
  continue
286
323
  }
287
324
 
325
+ const found = noteDrift(actx, report, copies, kind, source)
326
+
288
327
  if ('target' === kind) {
289
- checkTarget(actx, source, report, selected)
328
+ checkTarget(actx, source, report, found, selected)
290
329
  }
291
330
 
292
331
  if ('edition' === kind) {
293
- checkEdition(actx, source, report)
332
+ checkEdition(actx, source, report, found)
294
333
  }
295
334
 
296
335
  // Only an ACTIVE feature has source copied out; an inactive one's
@@ -298,10 +337,10 @@ async function doctor(
298
337
  if ('feature' === kind &&
299
338
  (false !== (model as any)?.main?.[KIT]?.feature?.[name]?.active ||
300
339
  true === selected?.includes(name))) {
301
- checkFeatureSource(actx, source, targets, report)
340
+ checkFeatureSource(actx, source, targets, found)
302
341
  }
303
342
 
304
- checkItemModel(actx, kind, source, report)
343
+ checkItemModel(actx, kind, source, report, found)
305
344
  }
306
345
  }
307
346
 
@@ -316,7 +355,7 @@ async function doctor(
316
355
  // authoritative either way.
317
356
  report.ok = 0 === report.forked.length + report.edited.length +
318
357
  report.stale.length + report.missing.length + report.superseded.length +
319
- report.orphanModel.length
358
+ report.orphanModel.length + report.outdated.length
320
359
 
321
360
  for (const [kind, note] of [
322
361
  ['forked', 'FORKED (will be reverted by `target add`)'],
@@ -329,12 +368,24 @@ async function doctor(
329
368
  ['orphanModel', 'ORPHAN MODEL FILE (on disk, included by nothing, read by nobody)'],
330
369
  ['resyncPending', 'RESYNC PENDING (predates provenance; `target add` updates it)'],
331
370
  ['aliasedDiff', 'aliased model differs from its origin (project-owned, not drift)'],
371
+ ['outdated', 'OUTDATED (unchanged since it was copied, but its source has moved on)'],
332
372
  ] as [keyof DoctorReport, string][]) {
333
373
  for (const file of (report[kind] as string[])) {
334
374
  log.info({ point: 'doctor-finding', kind, file, note: note + ': ' + file })
335
375
  }
336
376
  }
337
377
 
378
+ for (const item of Object.keys(report.byItem).sort()) {
379
+ const drift = report.byItem[item]
380
+ if (0 < drift.outdated) {
381
+ log.info({
382
+ point: 'doctor-outdated', item, ...drift,
383
+ note: outdatedNote(item, drift) + ' - `voxgig-sdkgen ' +
384
+ refreshCommand(item, drift.package) + '` refreshes them'
385
+ })
386
+ }
387
+ }
388
+
338
389
  log.info({
339
390
  point: 'doctor-end',
340
391
  ok: report.ok,
@@ -348,11 +399,13 @@ async function doctor(
348
399
  orphanModel: report.orphanModel.length,
349
400
  resyncPending: report.resyncPending.length,
350
401
  aliasedDiff: report.aliasedDiff.length,
402
+ outdated: report.outdated.length,
351
403
  note: report.ok ?
352
404
  ('.sdk matches the scaffold (' + report.additive.length + ' additive)') :
353
405
  ('.sdk has drifted: ' + report.forked.length + ' forked, ' +
354
406
  report.edited.length + ' edited, ' + report.stale.length + ' stale, ' +
355
- report.missing.length + ' missing')
407
+ report.missing.length + ' missing, ' + report.outdated.length +
408
+ ' outdated')
356
409
  })
357
410
 
358
411
  return { report }
@@ -412,7 +465,7 @@ function resolveDeclared(
412
465
 
413
466
 
414
467
  function checkTarget(
415
- actx: ActionContext, resolved: Source, report: DoctorReport,
468
+ actx: ActionContext, resolved: Source, report: DoctorReport, found: Found,
416
469
  selected?: string[],
417
470
  ) {
418
471
  const tname = resolved.name
@@ -466,7 +519,7 @@ function checkTarget(
466
519
  { log: quietLog(actx.log), fs: () => fs, folder: root, model },
467
520
  tfolder, torigname, tname, features)
468
521
 
469
- compareTrees(actx, report, trees, {
522
+ compareTrees(actx, report, found, trees, {
470
523
  excludes,
471
524
  foreign: (kind: string) => 'edited' === kind ?
472
525
  foreignFeatureSource(actx, resolved) : [],
@@ -477,6 +530,7 @@ function checkTarget(
477
530
  function compareTrees(
478
531
  actx: ActionContext,
479
532
  report: DoctorReport,
533
+ found: Found,
480
534
  trees: TreeCompare[],
481
535
  opts?: {
482
536
  excludes?: RegExp[],
@@ -518,7 +572,7 @@ function compareTrees(
518
572
 
519
573
  for (const rel of expected) {
520
574
  if (!actualSet.has(rel)) {
521
- report.missing.push(label + rel)
575
+ found('missing', label + rel)
522
576
  continue
523
577
  }
524
578
 
@@ -535,7 +589,7 @@ function compareTrees(
535
589
 
536
590
  if (differs(fs, from, Path.join(tree.project, rel),
537
591
  model, tree.replace, undefined, tree.rewrite)) {
538
- report[tree.kind].push(label + rel)
592
+ found(tree.kind, label + rel)
539
593
  }
540
594
  }
541
595
 
@@ -553,7 +607,7 @@ function compareTrees(
553
607
  report.additive.push(label + rel)
554
608
  }
555
609
  else {
556
- report.stale.push(label + rel)
610
+ found('stale', label + rel)
557
611
  }
558
612
  }
559
613
  }
@@ -561,7 +615,7 @@ function compareTrees(
561
615
 
562
616
 
563
617
  function checkEdition(
564
- actx: ActionContext, resolved: Source, report: DoctorReport,
618
+ actx: ActionContext, resolved: Source, report: DoctorReport, found: Found,
565
619
  ) {
566
620
  const fs = actx.fs()
567
621
  const root = actx.folder
@@ -593,7 +647,7 @@ function checkEdition(
593
647
  } as TreeCompare]
594
648
  })
595
649
 
596
- compareTrees(actx, report, trees)
650
+ compareTrees(actx, report, found, trees)
597
651
  }
598
652
 
599
653
 
@@ -677,7 +731,7 @@ function checkFeatureSource(
677
731
  actx: ActionContext,
678
732
  feature: Source,
679
733
  targets: Map<string, Source>,
680
- report: DoctorReport,
734
+ found: Found,
681
735
  ) {
682
736
  const fs = actx.fs()
683
737
  const root = actx.folder
@@ -689,14 +743,14 @@ function checkFeatureSource(
689
743
  const label = 'tm/' + tname + '/' + rel
690
744
 
691
745
  if (!fs.existsSync(project)) {
692
- report.missing.push(label)
746
+ found('missing', label)
693
747
  continue
694
748
  }
695
749
 
696
750
  // The same map `feature add` copies with — see helpers/stdrep. A
697
751
  // different one here would report every substituted file as edited.
698
752
  if (differs(fs, from, project, model, templateReplacements(model, tname))) {
699
- report.edited.push(label)
753
+ found('edited', label)
700
754
  }
701
755
  }
702
756
  }
@@ -705,6 +759,7 @@ function checkFeatureSource(
705
759
 
706
760
  function checkItemModel(
707
761
  actx: ActionContext, kind: string, source: Source, report: DoctorReport,
762
+ found: Found,
708
763
  ) {
709
764
  const name = source.name
710
765
  const origname = source.origname
@@ -723,7 +778,7 @@ function checkItemModel(
723
778
  const label = 'model/' + kind + '/' + Path.basename(project)
724
779
 
725
780
  if (!fs.existsSync(project)) {
726
- report.missing.push(label)
781
+ found('missing', label)
727
782
  return
728
783
  }
729
784
 
@@ -746,7 +801,92 @@ function checkItemModel(
746
801
  return
747
802
  }
748
803
 
749
- report.forked.push(label)
804
+ found('forked', label)
805
+ }
806
+
807
+
808
+ // Files every finding through one place: a forked or edited copy the record
809
+ // shows untouched is outdated instead, and every item keeps a tally.
810
+ function noteDrift(
811
+ actx: ActionContext, report: DoctorReport, copies: CopyRecord,
812
+ kind: string, source: Source,
813
+ ): Found {
814
+ const fs = actx.fs()
815
+ const item = itemKey(kind, source.name)
816
+ const copied = copies.items[item]
817
+
818
+ return (category: Category, label: string) => {
819
+ let into: Category | 'outdated' = category
820
+
821
+ if ('forked' === category || 'edited' === category) {
822
+ const same = untouched(fs, actx.folder, copies, label)
823
+
824
+ if (true === same) {
825
+ into = 'outdated'
826
+ }
827
+ else if (undefined === same) {
828
+ report.unrecorded.push(label)
829
+ }
830
+ }
831
+
832
+ report[into].push(label)
833
+
834
+ const drift = report.byItem[item] = report.byItem[item] ?? {
835
+ ...pkgOf(source.package ?? copied?.package),
836
+ ...(null == copied?.version ? {} : { from: copied.version }),
837
+ ...(null == source.version ? {} : { to: source.version }),
838
+ outdated: 0,
839
+ changed: 0,
840
+ }
841
+
842
+ if ('outdated' === into) {
843
+ drift.outdated++
844
+ }
845
+ else {
846
+ drift.changed++
847
+ }
848
+ }
849
+ }
850
+
851
+
852
+ function pkgOf(name?: string): { package?: string } {
853
+ return null == name ? {} : { package: name }
854
+ }
855
+
856
+
857
+ // The command that brings an item's copies up to the installed source. The
858
+ // package form checks first and refuses to overwrite a local edit.
859
+ function refreshCommand(item: string, pkg?: string): string {
860
+ if (null != pkg) {
861
+ return 'package update ' + pkg + ' --no-fetch'
862
+ }
863
+
864
+ const [kind, ...name] = item.split('/')
865
+ return kind + ' add ' + name.join('/')
866
+ }
867
+
868
+
869
+ function outdatedNote(item: string, drift: ItemDrift): string {
870
+ return item.replace('/', ' ') + ': ' + drift.outdated +
871
+ ' file(s) are unchanged copies' + sinceCopied(drift.package, [drift])
872
+ }
873
+
874
+
875
+ // Names both versions only when they differ: a source that changed under the
876
+ // same version (a linked checkout, say) is still a source that moved on.
877
+ function sinceCopied(pkg: string | undefined, drift: ItemDrift[]): string {
878
+ const froms = Array.from(new Set(drift.map((d: ItemDrift) => d.from)))
879
+ const to = drift[0].to
880
+ const from = 1 === froms.length ? froms[0] : undefined
881
+ const name = pkg ?? 'their source'
882
+
883
+ if (null != from && null != to && from !== to) {
884
+ return ' from ' + name + ' ' + from + ', but ' + name + ' ' + to +
885
+ ' is installed'
886
+ }
887
+
888
+ return ', but ' + name + (null == to ? '' : ' ' + to) +
889
+ ' has changed since they were copied'
750
890
  }
751
891
 
752
892
 
@@ -868,13 +1008,19 @@ function renderPair(
868
1008
  const rawsrc = fs.readFileSync(scaffoldPath, 'utf8')
869
1009
  const src = null == rewrite ? rawsrc : rewrite(rawsrc)
870
1010
 
1011
+ // git may rewrite line endings on checkout (core.autocrlf).
871
1012
  return {
872
- expected: template(src, model, { replace }),
873
- actual: fs.readFileSync(projectPath, 'utf8'),
1013
+ expected: lfEndings(template(src, model, { replace })),
1014
+ actual: lfEndings(fs.readFileSync(projectPath, 'utf8')),
874
1015
  }
875
1016
  }
876
1017
 
877
1018
 
1019
+ function lfEndings(text: string): string {
1020
+ return text.replace(/\r\n/g, '\n')
1021
+ }
1022
+
1023
+
878
1024
  function quietLog(log: any): any {
879
1025
  const noop = () => { }
880
1026
  const quiet: any = { info: noop, debug: noop, warn: log.warn.bind(log), error: noop, trace: noop, fatal: noop }
@@ -883,12 +1029,92 @@ function quietLog(log: any): any {
883
1029
  }
884
1030
 
885
1031
 
1032
+ function silentLog(): any {
1033
+ const noop = () => { }
1034
+ const silent: any = { info: noop, debug: noop, warn: noop, error: noop, trace: noop, fatal: noop }
1035
+ silent.child = () => silent
1036
+ return silent
1037
+ }
1038
+
1039
+
1040
+ // What `generate` checks before it reads the copies: the active items only,
1041
+ // and a warning per refresh command rather than a failure, since a
1042
+ // difference may be a deliberate edit.
1043
+ async function copyCheck(actx: ActionContext): Promise<string[]> {
1044
+ const active = (kind: string, name: string) =>
1045
+ false !== kindCollection(actx.model, kind)?.[name]?.active
1046
+
1047
+ const res: any = await doctor({ ...actx, log: silentLog() }, active)
1048
+
1049
+ return copyWarnings(res.report)
1050
+ }
1051
+
1052
+
1053
+ function copyWarnings(report: DoctorReport): string[] {
1054
+ const groups = new Map<string, { items: string[], files: number, drift: ItemDrift[] }>()
1055
+
1056
+ const add = (key: string, item: string, files: number, drift: ItemDrift) => {
1057
+ const group = groups.get(key) ?? { items: [], files: 0, drift: [] }
1058
+ group.items.push(item.replace('/', ' '))
1059
+ group.files += files
1060
+ group.drift.push(drift)
1061
+ groups.set(key, group)
1062
+ }
1063
+
1064
+ for (const item of Object.keys(report.byItem).sort()) {
1065
+ const drift = report.byItem[item]
1066
+ const command = refreshCommand(item, drift.package)
1067
+
1068
+ if (0 < drift.outdated) {
1069
+ add('outdated\n' + command, item, drift.outdated, drift)
1070
+ }
1071
+
1072
+ if (0 < drift.changed) {
1073
+ add('changed\n' + command, item, drift.changed, drift)
1074
+ }
1075
+ }
1076
+
1077
+ const lines: string[] = []
1078
+
1079
+ for (const [key, group] of groups) {
1080
+ const [what, command] = key.split('\n')
1081
+ const pkg = group.drift[0].package
1082
+ const to = group.drift[0].to
1083
+ const installed = null == pkg ? 'their source' :
1084
+ (pkg + (null == to ? '' : ' ' + to))
1085
+ const refresh = '`npx voxgig-sdkgen ' + command + '`'
1086
+
1087
+ if ('outdated' === what) {
1088
+ lines.push(group.items.join(', ') + ': ' + group.files +
1089
+ ' file(s) in .sdk are unchanged copies' +
1090
+ sinceCopied(pkg, group.drift) + ', so this run generates from ' +
1091
+ 'older templates. Run ' + refresh + ' to refresh them.')
1092
+ }
1093
+ else {
1094
+ lines.push(group.items.join(', ') + ': ' + group.files +
1095
+ ' file(s) in .sdk differ from what ' + installed + ' installs: ' +
1096
+ 'changed in this project, or copied before sdkgen recorded its ' +
1097
+ 'copies. `npx voxgig-sdkgen doctor` lists them; ' + refresh +
1098
+ ' refreshes them' +
1099
+ (null == pkg ? ', discarding any local edit.' :
1100
+ ', and refuses before it overwrites a local edit.'))
1101
+ }
1102
+ }
1103
+
1104
+ return lines
1105
+ }
1106
+
1107
+
886
1108
  export type {
887
1109
  DoctorReport,
888
1110
  DoctorScope,
1111
+ ItemDrift,
889
1112
  }
890
1113
 
891
1114
  export {
892
1115
  action_doctor,
893
1116
  doctor,
1117
+ copyCheck,
1118
+ copyWarnings,
1119
+ refreshCommand,
894
1120
  }