aontu 0.63.0 → 0.65.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 (101) hide show
  1. package/README.md +8 -8
  2. package/dist/agentsmd.js +3 -3
  3. package/dist/agentsmd.js.map +1 -1
  4. package/dist/aontu.d.ts +3 -3
  5. package/dist/aontu.js +4 -6
  6. package/dist/aontu.js.map +1 -1
  7. package/dist/aontumodel.js +6 -12
  8. package/dist/aontumodel.js.map +1 -1
  9. package/dist/casing.d.ts +5 -0
  10. package/dist/casing.js +90 -0
  11. package/dist/casing.js.map +1 -0
  12. package/dist/cli.d.ts +16 -3
  13. package/dist/cli.js +653 -455
  14. package/dist/cli.js.map +1 -1
  15. package/dist/compat.d.ts +7 -0
  16. package/dist/compat.js +113 -0
  17. package/dist/compat.js.map +1 -0
  18. package/dist/helpdoc.js +5 -5
  19. package/dist/helpdoc.js.map +1 -1
  20. package/dist/hints.js +14 -5
  21. package/dist/hints.js.map +1 -1
  22. package/dist/lang.d.ts +1 -0
  23. package/dist/lang.js +8 -0
  24. package/dist/lang.js.map +1 -1
  25. package/dist/lsp.d.ts +1 -1
  26. package/dist/mcp.js +0 -36
  27. package/dist/mcp.js.map +1 -1
  28. package/dist/mod.d.ts +18 -3
  29. package/dist/mod.js +117 -46
  30. package/dist/mod.js.map +1 -1
  31. package/dist/pkg-net.d.ts +227 -0
  32. package/dist/pkg-net.js +1598 -0
  33. package/dist/pkg-net.js.map +1 -0
  34. package/dist/pkg-zip.d.ts +8 -0
  35. package/dist/pkg-zip.js +196 -0
  36. package/dist/pkg-zip.js.map +1 -0
  37. package/dist/pkg.d.ts +159 -0
  38. package/dist/pkg.js +767 -0
  39. package/dist/pkg.js.map +1 -0
  40. package/dist/profile.d.ts +9 -0
  41. package/dist/profile.js +28 -0
  42. package/dist/profile.js.map +1 -0
  43. package/dist/report-sarif.js +1 -1
  44. package/dist/report-sarif.js.map +1 -1
  45. package/dist/subsume.d.ts +3 -1
  46. package/dist/subsume.js +2 -0
  47. package/dist/subsume.js.map +1 -1
  48. package/dist/trace.d.ts +21 -0
  49. package/dist/trace.js +107 -0
  50. package/dist/trace.js.map +1 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/dist/unify.js +1 -6
  53. package/dist/unify.js.map +1 -1
  54. package/dist/val/CmpFuncVal.d.ts +2 -0
  55. package/dist/val/CmpFuncVal.js +47 -13
  56. package/dist/val/CmpFuncVal.js.map +1 -1
  57. package/dist/val/NomFuncVal.js +11 -13
  58. package/dist/val/NomFuncVal.js.map +1 -1
  59. package/dist/val/ReferFuncVal.js +3 -0
  60. package/dist/val/ReferFuncVal.js.map +1 -1
  61. package/package.json +1 -1
  62. package/skill/SKILL.md +3 -3
  63. package/skill/error-codes.md +4 -4
  64. package/skill/examples.md +4 -4
  65. package/skill/grammar-card.md +1 -1
  66. package/skill/init/check.sh +2 -2
  67. package/skill/tasks.md +14 -12
  68. package/src/agentsmd.ts +3 -3
  69. package/src/aontu.ts +3 -8
  70. package/src/aontumodel.ts +6 -12
  71. package/src/casing.ts +95 -0
  72. package/src/cli.ts +758 -495
  73. package/src/compat.ts +148 -0
  74. package/src/helpdoc.ts +5 -5
  75. package/src/hints.ts +14 -7
  76. package/src/lang.ts +9 -1
  77. package/src/mcp.ts +0 -39
  78. package/src/mod.ts +142 -51
  79. package/src/pkg-net.ts +1945 -0
  80. package/src/pkg-zip.ts +228 -0
  81. package/src/pkg.ts +1018 -0
  82. package/src/profile.ts +42 -0
  83. package/src/report-sarif.ts +1 -1
  84. package/src/subsume.ts +2 -2
  85. package/src/trace.ts +157 -0
  86. package/src/unify.ts +1 -6
  87. package/src/val/CmpFuncVal.ts +66 -20
  88. package/src/val/NomFuncVal.ts +3 -5
  89. package/src/val/ReferFuncVal.ts +4 -0
  90. package/dist/lower.d.ts +0 -23
  91. package/dist/lower.js +0 -528
  92. package/dist/lower.js.map +0 -1
  93. package/dist/mod-tool.d.ts +0 -58
  94. package/dist/mod-tool.js +0 -403
  95. package/dist/mod-tool.js.map +0 -1
  96. package/dist/render.d.ts +0 -53
  97. package/dist/render.js +0 -432
  98. package/dist/render.js.map +0 -1
  99. package/src/lower.ts +0 -586
  100. package/src/mod-tool.ts +0 -561
  101. package/src/render.ts +0 -580
package/src/pkg.ts ADDED
@@ -0,0 +1,1018 @@
1
+ /* Copyright (c) 2025 Richard Rodger, MIT License */
2
+
3
+
4
+ import {
5
+ readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync,
6
+ lstatSync, copyFileSync, unlinkSync, rmSync,
7
+ } from 'node:fs'
8
+ import { join as pathJoin, dirname as pathDirname } from 'node:path'
9
+
10
+ import {
11
+ parseModuleRef, validateModulePath, moduleDir, lockJson, isAlias,
12
+ cacheStoreDir, cacheDownloadDir, localFileExt, PKG_FILE, LOCK_FILE, META_DIR, VENDOR_DIR,
13
+ } from './mod'
14
+ import { zipCanonical, sha256Hex, cmpBytes } from './pkg-zip'
15
+ import { subsume } from './subsume'
16
+ import { compatOutcome } from './compat'
17
+ import { includeFormat } from './lang'
18
+ import type { ZipEntry } from './pkg-zip'
19
+
20
+ export const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
21
+
22
+ // params.archive: what a consumer refuses to unpack, and so what a
23
+ // publisher refuses to mint. A record, so a test can lower them.
24
+ export const ARCHIVE_LIMITS = {
25
+ bytes: 16777216, unpacked: 67108864, files: 4096, fileBytes: 8388608,
26
+ }
27
+
28
+
29
+ // One entry of the lockfile, and of a tidy report. `key` is the lock
30
+ // key: a package path, or `alias:<name>` with `pkg` naming the package.
31
+ export type LockEntry = {
32
+ key: string
33
+ v: string
34
+ canon: string
35
+ // The canonical archive's digest: computable from any tree.
36
+ archive: string
37
+ // The signed manifest's digest, present only for a package acquired
38
+ // from a repository.
39
+ manifest?: string
40
+ pkg?: string
41
+ }
42
+
43
+ export type PkgTidyReport = {
44
+ verdict: 'ok' | 'missing' | 'error'
45
+ // The resolved closure, sorted by key.
46
+ lock: LockEntry[]
47
+ missing: string[]
48
+ unevaluable: string[]
49
+ // Files the allowlist refuses, as `<key>: <file>`, sorted.
50
+ forbidden: string[]
51
+ }
52
+
53
+ export type PkgMismatch = {
54
+ key: string
55
+ pin: 'archive' | 'manifest' | 'canon'
56
+ want: string
57
+ got: string
58
+ }
59
+
60
+ export type PkgVerifyReport = {
61
+ verdict: 'ok' | 'mismatch' | 'unlocked' | 'missing'
62
+ verified: string[]
63
+ // What the lockfile pins against what the store now holds or means,
64
+ // bytes before meaning, sorted by key.
65
+ mismatched: PkgMismatch[]
66
+ // Dependencies the project declares that the lockfile does not name,
67
+ // sorted. A tidy is what fills them in.
68
+ unlocked: string[]
69
+ // Locked packages present in no store, sorted.
70
+ missing: string[]
71
+ }
72
+
73
+ export type PkgVendorReport = {
74
+ verdict: 'ok' | 'missing'
75
+ vendored: string[]
76
+ missing: string[]
77
+ }
78
+
79
+ export type PkgRefreezeReport = {
80
+ verdict: 'ok' | 'missing' | 'error'
81
+ repinned: { key: string, from: string, to: string }[]
82
+ unchanged: string[]
83
+ missing: string[]
84
+ unevaluable: string[]
85
+ }
86
+
87
+ export type PkgTreeNode = { key: string, v: string, deps: string[] }
88
+
89
+ export type PkgTreeReport = {
90
+ verdict: 'ok' | 'missing'
91
+ root: string
92
+ nodes: PkgTreeNode[]
93
+ missing: string[]
94
+ }
95
+
96
+
97
+ export type PkgToolEval = (src: string, path: string) =>
98
+ {
99
+ gen: any, hash: string, canon: string,
100
+ ok: boolean,
101
+ }
102
+
103
+
104
+ export type PkgToolOptions = {
105
+ // The content-addressed user cache. Empty means no cache is
106
+ // consulted, which is a store that misses rather than an error.
107
+ cache?: string
108
+ eval: PkgToolEval
109
+ }
110
+
111
+
112
+ export type Dependency = { v: string, pkg?: string }
113
+
114
+
115
+ // The `dep` block a package file declares: key -> minimum version, and
116
+ // for an alias the package it names.
117
+ export function declaredDeps(file: string, options: PkgToolOptions):
118
+ Record<string, Dependency> {
119
+ if (!existsSync(file)) {
120
+ return {}
121
+ }
122
+
123
+ const gen: any = options.eval(readFileSync(file, 'utf8'), file).gen
124
+ const dep = gen?.dep
125
+ if (null == dep || 'object' !== typeof dep) {
126
+ return {}
127
+ }
128
+
129
+ const out: Record<string, Dependency> = {}
130
+ for (const key of Object.keys(dep)) {
131
+ const v = dep[key]?.v
132
+ if ('string' === typeof v && '' !== v) {
133
+ const pkg = dep[key]?.pkg
134
+ out[key] = { v, ...('string' === typeof pkg && '' !== pkg ? { pkg } : {}) }
135
+ }
136
+ }
137
+ return out
138
+ }
139
+
140
+
141
+ export function versionCompare(a: string, b: string): number {
142
+ const ap = a.split('.')
143
+ const bp = b.split('.')
144
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
145
+ // A part the shorter version does not have is ZERO, so `1.2` and
146
+ // `1.2.0` are the same version -- which is what everyone means by
147
+ // them, and what a lockfile rewritten from either must agree on.
148
+ const x = ap[i] ?? '0'
149
+ const y = bp[i] ?? '0'
150
+ if (x === y) {
151
+ continue
152
+ }
153
+ const xn = /^\d+$/.test(x)
154
+ const yn = /^\d+$/.test(y)
155
+ if (xn && yn) {
156
+ const xs = x.replace(/^0+(?=\d)/, '')
157
+ const ys = y.replace(/^0+(?=\d)/, '')
158
+ if (xs === ys) {
159
+ continue
160
+ }
161
+ return xs.length !== ys.length ? (xs.length < ys.length ? -1 : 1) : (xs < ys ? -1 : 1)
162
+ }
163
+ if (xn !== yn) {
164
+ return xn ? -1 : 1
165
+ }
166
+ return x < y ? -1 : 1
167
+ }
168
+ return 0
169
+ }
170
+
171
+
172
+ // A last element with an extension the include table knows is a
173
+ // local file to the resolver, whatever declares it, so it names no
174
+ // package.
175
+ export function usableKey(key: string): boolean {
176
+ const ref = parseModuleRef(key)
177
+ if (undefined === ref || ref.path !== key) {
178
+ return false
179
+ }
180
+ if (isAlias(key)) {
181
+ return true
182
+ }
183
+ const ext = localFileExt(key)
184
+ return undefined === validateModulePath(key) &&
185
+ (undefined === ext || undefined === includeFormat(ext))
186
+ }
187
+
188
+
189
+ // The directory a package is in, in the local stores: the project's
190
+ // vendor tree first, then the cache under the hash the lockfile pins,
191
+ // then the cache under the hash the repository's manifest for that
192
+ // version pins, which the consumer's own pin need not equal.
193
+ export function storeDir(
194
+ root: string, key: string, canon: string, pkg: string,
195
+ options: PkgToolOptions, v?: string,
196
+ ): string | undefined {
197
+ const stores = [moduleDir(pathJoin(root, META_DIR, VENDOR_DIR), key)]
198
+ if (null != options.cache && '' !== pkg) {
199
+ if ('' !== canon) {
200
+ stores.push(cacheStoreDir(options.cache, canon, pkg))
201
+ }
202
+ const served = null == v ? undefined : downloadedCanon(options.cache, pkg, v)
203
+ if (undefined !== served && served !== canon) {
204
+ stores.push(cacheStoreDir(options.cache, served, pkg))
205
+ }
206
+ }
207
+ return stores.find((d) => existsSync(pathJoin(d, PKG_FILE)))
208
+ }
209
+
210
+ // The canon the repository's manifest pins for a version this client
211
+ // downloaded, or undefined where none was.
212
+ export function downloadedCanon(cache: string, pkg: string, v: string): string | undefined {
213
+ const file = pathJoin(cacheDownloadDir(cache, pkg), v + '.manifest')
214
+ if (!existsSync(file)) {
215
+ return undefined
216
+ }
217
+ let doc: any
218
+ try {
219
+ doc = JSON.parse(readFileSync(file, 'utf8'))
220
+ }
221
+ catch {
222
+ return undefined
223
+ }
224
+ const canon = doc?.modules?.[0]?.canon
225
+ return 'string' === typeof canon ? canon : undefined
226
+ }
227
+
228
+
229
+ export function readLock(root: string): Record<string, LockEntry> {
230
+ const file = pathJoin(root, META_DIR, LOCK_FILE)
231
+ if (!existsSync(file)) {
232
+ return {}
233
+ }
234
+
235
+ let lock: any
236
+ try {
237
+ lock = JSON.parse(lockJson(readFileSync(file, 'utf8')))
238
+ }
239
+ catch {
240
+ return {}
241
+ }
242
+
243
+ const out: Record<string, LockEntry> = {}
244
+ for (const key of Object.keys(lock?.lock ?? {})) {
245
+ const e = lock.lock[key]
246
+ const str = (k: string): string => 'string' === typeof e?.[k] ? e[k] : ''
247
+ out[key] = {
248
+ key,
249
+ v: str('v'),
250
+ canon: str('canon'),
251
+ archive: str('archive'),
252
+ ...('' === str('manifest') ? {} : { manifest: str('manifest') }),
253
+ ...('' === str('pkg') ? {} : { pkg: str('pkg') }),
254
+ }
255
+ }
256
+ return out
257
+ }
258
+
259
+
260
+ export function lockText(entries: LockEntry[], options: PkgToolOptions): string {
261
+ const parts = entries.map((e) =>
262
+ JSON.stringify(e.key) + ':{' +
263
+ '"archive":' + JSON.stringify(e.archive) + ',' +
264
+ '"canon":' + JSON.stringify(e.canon) + ',' +
265
+ (null == e.manifest ? '' : '"manifest":' + JSON.stringify(e.manifest) + ',') +
266
+ (null == e.pkg ? '' : '"pkg":' + JSON.stringify(e.pkg) + ',') +
267
+ '"v":' + JSON.stringify(e.v) + '}')
268
+ return options.eval('{"lock":{' + parts.join(',') + '}}', LOCK_FILE).canon
269
+ }
270
+
271
+
272
+ // The generated-file header. A lockfile is machine-written, and the
273
+ // file says so where an editor will see it.
274
+ export const LOCK_HEADER =
275
+ '# pkg-lock.aon (generated by `aontu sync`; do not edit)\n'
276
+
277
+
278
+ export function writeLock(root: string, entries: LockEntry[],
279
+ options: PkgToolOptions): void {
280
+ mkdirSync(pathJoin(root, META_DIR), { recursive: true })
281
+ writeFileSync(pathJoin(root, META_DIR, LOCK_FILE),
282
+ LOCK_HEADER + lockText(entries, options) + '\n')
283
+ }
284
+
285
+
286
+ // What a package file says about ITSELF. Distinct from `declaredDeps`,
287
+ // which reads what it says about others.
288
+ export type PackageSelf = {
289
+ path: string
290
+ version: string
291
+ main: string
292
+ publish: 'public' | 'private'
293
+ moved?: string
294
+ retract: string[]
295
+ }
296
+
297
+ export function packageSelf(dir: string, options: PkgToolOptions): PackageSelf {
298
+ const file = pathJoin(dir, PKG_FILE)
299
+ const self: PackageSelf = {
300
+ path: '', version: '', main: 'main.aon', publish: 'private', retract: [],
301
+ }
302
+ if (!existsSync(file)) {
303
+ return self
304
+ }
305
+ const gen: any = options.eval(readFileSync(file, 'utf8'), file).gen
306
+ const pkg = gen?.pkg
307
+ const str = (v: any): string => 'string' === typeof v ? v : ''
308
+ self.path = str(pkg?.path)
309
+ self.version = str(pkg?.version)
310
+ self.main = '' === str(pkg?.main) ? 'main.aon' : str(pkg?.main)
311
+ self.publish = 'public' === gen?.publish ? 'public' : 'private'
312
+ if ('' !== str(gen?.moved)) {
313
+ self.moved = str(gen.moved)
314
+ }
315
+ if (Array.isArray(gen?.retract)) {
316
+ self.retract = gen.retract.filter((v: any) => 'string' === typeof v)
317
+ }
318
+ return self
319
+ }
320
+
321
+
322
+ // What a package may contain (REPOSITORY.0.md §9.4): an enumerated
323
+ // allowlist, refused by default. The data half is ADR-012's table.
324
+ export const ARCHIVE_SOURCE_EXT = ['aon', 'aontu']
325
+ export const ARCHIVE_DATA_EXT = [
326
+ 'json', 'jsonld', 'jsonc', 'json5', 'jsonic', 'jsc', 'toml', 'yaml', 'yml',
327
+ 'ini',
328
+ ]
329
+ export const ARCHIVE_TEXT_EXT = ['md', 'txt']
330
+ export const ARCHIVE_NAMED = ['LICENSE', 'NOTICE']
331
+
332
+ const ADMITTED_EXT = new Set([
333
+ ...ARCHIVE_SOURCE_EXT, ...ARCHIVE_DATA_EXT, ...ARCHIVE_TEXT_EXT])
334
+
335
+
336
+ export function archiveAdmits(rel: string): boolean {
337
+ const elems = rel.split('/')
338
+ if (elems.some((e) => e.startsWith('.'))) {
339
+ return false
340
+ }
341
+ const name = elems[elems.length - 1]
342
+ if (ARCHIVE_NAMED.includes(name)) {
343
+ return true
344
+ }
345
+ const m = /^[^.].*\.([^.]+)$/.exec(name)
346
+ return null != m && ADMITTED_EXT.has(m[1].toLowerCase())
347
+ }
348
+
349
+
350
+ export type ArchiveFile = { path: string, digest: string, size: number }
351
+
352
+ export type Archive = {
353
+ zip: Uint8Array
354
+ digest: string
355
+ size: number
356
+ files: ArchiveFile[]
357
+ // Entries the allowlist refuses, sorted: a symlink, an executable, a
358
+ // dotfile directory, or a name outside the table.
359
+ forbidden: string[]
360
+ }
361
+
362
+
363
+ function walkTree(dir: string, prefix: string, out: ZipEntry[],
364
+ forbidden: string[]): void {
365
+ for (const name of readdirSync(dir).sort(cmpBytes)) {
366
+ if ('' === prefix && META_DIR === name) {
367
+ continue
368
+ }
369
+ const full = pathJoin(dir, name)
370
+ const rel = '' === prefix ? name : prefix + '/' + name
371
+ const st = lstatSync(full)
372
+ if (st.isSymbolicLink()) {
373
+ forbidden.push(rel)
374
+ continue
375
+ }
376
+ if (st.isDirectory()) {
377
+ if (name.startsWith('.')) {
378
+ forbidden.push(rel + '/')
379
+ continue
380
+ }
381
+ walkTree(full, rel, out, forbidden)
382
+ continue
383
+ }
384
+ if (!st.isFile() || 0 !== (st.mode & 0o111) || !archiveAdmits(rel)) {
385
+ forbidden.push(rel)
386
+ continue
387
+ }
388
+ out.push({ path: rel, data: new Uint8Array(readFileSync(full)) })
389
+ }
390
+ }
391
+
392
+
393
+ // The canonical archive of a tree, and every file's own digest.
394
+ export function archiveOf(dir: string): Archive {
395
+ const entries: ZipEntry[] = []
396
+ const forbidden: string[] = []
397
+ walkTree(dir, '', entries, forbidden)
398
+ const zip = zipCanonical(entries)
399
+ return {
400
+ zip,
401
+ digest: sha256Hex(zip),
402
+ size: zip.length,
403
+ files: entries.map((e) => ({
404
+ path: e.path, digest: sha256Hex(e.data), size: e.data.length,
405
+ })),
406
+ forbidden: forbidden.sort(cmpBytes),
407
+ }
408
+ }
409
+
410
+
411
+ // The pin a locked tree carries beside it, when it was acquired from a
412
+ // repository: the served manifest, verbatim.
413
+ export function storedManifest(dir: string):
414
+ { digest: string, files: ArchiveFile[] } | undefined {
415
+ const file = pathJoin(dir, META_DIR, 'manifest.aon')
416
+ if (!existsSync(file)) {
417
+ return undefined
418
+ }
419
+ const bytes = new Uint8Array(readFileSync(file))
420
+ let doc: any
421
+ try {
422
+ doc = JSON.parse(lockJson(new TextDecoder().decode(bytes)))
423
+ }
424
+ catch {
425
+ return { digest: sha256Hex(bytes), files: [] }
426
+ }
427
+ const files = Array.isArray(doc?.archive?.files) ? doc.archive.files : []
428
+ return { digest: sha256Hex(bytes), files }
429
+ }
430
+
431
+
432
+ function mainOf(dir: string, options: PkgToolOptions): string {
433
+ return packageSelf(dir, options).main
434
+ }
435
+
436
+
437
+ // The lock entry a store tree yields for a key: its version as
438
+ // selected, its canon-hash, its archive digest, and the manifest digest
439
+ // where a manifest is kept beside it.
440
+ function pinTree(
441
+ key: string, dir: string, v: string, pkg: string | undefined,
442
+ options: PkgToolOptions,
443
+ ): { entry?: LockEntry, unevaluable?: true, forbidden: string[] } {
444
+ const main = pathJoin(dir, mainOf(dir, options))
445
+ const got = existsSync(main) ?
446
+ options.eval(readFileSync(main, 'utf8'), main) : undefined
447
+ if (null != got && !got.ok) {
448
+ return { unevaluable: true, forbidden: [] }
449
+ }
450
+ const archive = archiveOf(dir)
451
+ const manifest = storedManifest(dir)
452
+ return {
453
+ entry: {
454
+ key,
455
+ v,
456
+ canon: null == got ? '' : got.hash,
457
+ archive: archive.digest,
458
+ ...(null == manifest ? {} : { manifest: manifest.digest }),
459
+ ...(null == pkg ? {} : { pkg }),
460
+ },
461
+ forbidden: archive.forbidden.map((f) => key + ': ' + f),
462
+ }
463
+ }
464
+
465
+
466
+ // `aontu pkg tidy`: resolve the closure by MVS and rewrite the lockfile.
467
+ export function pkgTidy(root: string, options: PkgToolOptions): PkgTidyReport {
468
+ const report = pkgResolve(root, options)
469
+ if ('ok' === report.verdict) {
470
+ writeLock(root, report.lock, options)
471
+ }
472
+ return report
473
+ }
474
+
475
+
476
+ // The resolution alone, for a caller that decides whether to write.
477
+ export function pkgResolve(root: string, options: PkgToolOptions): PkgTidyReport {
478
+ const previous = readLock(root)
479
+ const selected: Record<string, Dependency> = {}
480
+ const missing: string[] = []
481
+
482
+ let frontier = declaredDeps(pathJoin(root, PKG_FILE), options)
483
+ for (; 0 < Object.keys(frontier).length;) {
484
+ const next: Record<string, Dependency> = {}
485
+
486
+ for (const key of Object.keys(frontier)) {
487
+ const want = frontier[key]
488
+ const have = selected[key]
489
+ if (null != have && 0 <= versionCompare(have.v, want.v)) {
490
+ continue
491
+ }
492
+ selected[key] = { ...have, ...want }
493
+
494
+ if (!usableKey(key)) {
495
+ // A key this tooling cannot act on names nothing any store
496
+ // can hold: the same answer as an absent package.
497
+ missing.push(key)
498
+ continue
499
+ }
500
+
501
+ const dir = storeDir(root, key, previous[key]?.canon ?? '',
502
+ targetOf(key, selected[key], previous[key]), options, selected[key].v)
503
+ if (undefined === dir) {
504
+ missing.push(key)
505
+ continue
506
+ }
507
+
508
+ const deps = declaredDeps(pathJoin(dir, PKG_FILE), options)
509
+ for (const dk of Object.keys(deps)) {
510
+ const bid = next[dk]
511
+ if (null == bid || 0 > versionCompare(bid.v, deps[dk].v)) {
512
+ next[dk] = { ...bid, ...deps[dk] }
513
+ }
514
+ }
515
+ }
516
+
517
+ frontier = next
518
+ }
519
+
520
+ const lock: LockEntry[] = []
521
+ const unevaluable: string[] = []
522
+ const forbidden: string[] = []
523
+ for (const key of Object.keys(selected).sort(cmpBytes)) {
524
+ if (missing.includes(key)) {
525
+ continue
526
+ }
527
+ const pkg = targetOf(key, selected[key], previous[key])
528
+ const dir = storeDir(root, key, previous[key]?.canon ?? '', pkg,
529
+ options, selected[key].v) as string
530
+ const pinned = pinTree(key, dir, selected[key].v,
531
+ isAlias(key) ? pkg : undefined, options)
532
+ forbidden.push(...pinned.forbidden)
533
+ if (pinned.unevaluable) {
534
+ unevaluable.push(key)
535
+ continue
536
+ }
537
+ lock.push(pinned.entry as LockEntry)
538
+ }
539
+
540
+ const uniqueMissing = [...new Set(missing)].sort(cmpBytes)
541
+ const uniqueUnevaluable = [...new Set(unevaluable)].sort(cmpBytes)
542
+ const held = 0 === uniqueMissing.length && 0 === uniqueUnevaluable.length &&
543
+ 0 === forbidden.length
544
+
545
+ return {
546
+ verdict: held ? 'ok' :
547
+ 0 < uniqueUnevaluable.length || 0 < forbidden.length ? 'error' : 'missing',
548
+ lock,
549
+ missing: uniqueMissing,
550
+ unevaluable: uniqueUnevaluable,
551
+ forbidden: forbidden.sort(cmpBytes),
552
+ }
553
+ }
554
+
555
+
556
+ // The package an alias key names: from the declaration, else from the
557
+ // previous lock; a package path names itself.
558
+ export function targetOf(key: string, dep?: Dependency, prev?: LockEntry): string {
559
+ if (!isAlias(key)) {
560
+ return key
561
+ }
562
+ return dep?.pkg ?? prev?.pkg ?? ''
563
+ }
564
+
565
+
566
+ export function pkgVerify(root: string, options: PkgToolOptions):
567
+ PkgVerifyReport {
568
+ const locked = readLock(root)
569
+ const verified: string[] = []
570
+ const mismatched: PkgMismatch[] = []
571
+ const missing: string[] = []
572
+
573
+ const declared = declaredDeps(pathJoin(root, PKG_FILE), options)
574
+ const unlocked = Object.keys(declared)
575
+ .filter((key) => null == locked[key]).sort(cmpBytes)
576
+
577
+ for (const key of Object.keys(locked).sort(cmpBytes)) {
578
+ const entry = locked[key]
579
+ if (!usableKey(key)) {
580
+ missing.push(key)
581
+ continue
582
+ }
583
+ const dir = storeDir(root, key, entry.canon, entry.pkg ?? key, options, entry.v)
584
+ if (undefined === dir) {
585
+ missing.push(key)
586
+ continue
587
+ }
588
+ const main = pathJoin(dir, mainOf(dir, options))
589
+ if (!existsSync(main)) {
590
+ missing.push(key)
591
+ continue
592
+ }
593
+
594
+ // BYTES BEFORE MEANING (ADR-019): the archive digest and the
595
+ // manifest's file list first, on fixed-size reads, then the one
596
+ // evaluation.
597
+ const before = mismatched.length
598
+ const archive = archiveOf(dir)
599
+ for (const f of archive.forbidden) {
600
+ mismatched.push({ key, pin: 'archive', want: entry.archive, got: 'forbidden: ' + f })
601
+ }
602
+ if (0 === archive.forbidden.length && entry.archive !== archive.digest) {
603
+ mismatched.push({ key, pin: 'archive', want: entry.archive, got: archive.digest })
604
+ }
605
+ const manifest = storedManifest(dir)
606
+ if (null == manifest) {
607
+ if (null != entry.manifest) {
608
+ mismatched.push({ key, pin: 'manifest', want: entry.manifest, got: '' })
609
+ }
610
+ }
611
+ else {
612
+ if (null != entry.manifest && entry.manifest !== manifest.digest) {
613
+ mismatched.push({ key, pin: 'manifest', want: entry.manifest, got: manifest.digest })
614
+ }
615
+ const listed = new Map(manifest.files.map((f) => [f.path, f.digest]))
616
+ for (const f of archive.files) {
617
+ if (listed.get(f.path) !== f.digest) {
618
+ mismatched.push({ key, pin: 'manifest', want: listed.get(f.path) ?? '',
619
+ got: f.path + ' ' + f.digest })
620
+ }
621
+ }
622
+ }
623
+ if (before !== mismatched.length) {
624
+ continue
625
+ }
626
+
627
+ const got = options.eval(readFileSync(main, 'utf8'), main)
628
+ const want = entry.canon
629
+ if (got.ok && want === got.hash) {
630
+ verified.push(key)
631
+ continue
632
+ }
633
+ mismatched.push({ key, pin: 'canon', want, got: got.ok ? got.hash : '' })
634
+ }
635
+
636
+ return {
637
+ verdict: 0 < mismatched.length ? 'mismatch' :
638
+ 0 < unlocked.length ? 'unlocked' :
639
+ 0 < missing.length ? 'missing' : 'ok',
640
+ verified,
641
+ mismatched,
642
+ unlocked,
643
+ missing: missing.sort(cmpBytes),
644
+ }
645
+ }
646
+
647
+
648
+ // `aontu pkg vendor`: materialise the locked closure into `aontu_meta/vendor/`.
649
+ export function pkgVendor(root: string, options: PkgToolOptions):
650
+ PkgVendorReport {
651
+ const locked = readLock(root)
652
+ const vendored: string[] = []
653
+ const missing: string[] = []
654
+
655
+ const vendorRoot = pathJoin(root, META_DIR, VENDOR_DIR)
656
+
657
+ for (const key of Object.keys(locked).sort(cmpBytes)) {
658
+ const entry = locked[key]
659
+ if (!usableKey(key)) {
660
+ missing.push(key)
661
+ continue
662
+ }
663
+
664
+ const from = storeDir(root, key, entry.canon, entry.pkg ?? key, options, entry.v)
665
+ if (undefined === from) {
666
+ missing.push(key)
667
+ continue
668
+ }
669
+
670
+ const to = moduleDir(vendorRoot, key)
671
+ if (from !== to) {
672
+ vendorCopy(from, to)
673
+ }
674
+ vendored.push(key)
675
+ }
676
+
677
+ return {
678
+ verdict: 0 === missing.length ? 'ok' : 'missing',
679
+ vendored,
680
+ missing: missing.sort(cmpBytes),
681
+ }
682
+ }
683
+
684
+
685
+ // A store tree into the vendor tree: the whole directory, less the
686
+ // store's own lock, so the copy resolves against the consumer's.
687
+ export function vendorCopy(from: string, to: string): void {
688
+ rmSync(to, { recursive: true, force: true })
689
+ copyTree(from, to)
690
+ const lock = pathJoin(to, META_DIR, LOCK_FILE)
691
+ if (existsSync(lock)) {
692
+ unlinkSync(lock)
693
+ }
694
+ }
695
+
696
+
697
+ // A whole package directory, copied as real files: the vendor tree is
698
+ // committed, and a link into a shared store is corruption waiting for
699
+ // an edit.
700
+ export function copyTree(from: string, to: string): void {
701
+ mkdirSync(to, { recursive: true })
702
+ for (const name of readdirSync(from).sort(cmpBytes)) {
703
+ const src = pathJoin(from, name)
704
+ const dst = pathJoin(to, name)
705
+ if (statSync(src).isDirectory()) {
706
+ copyTree(src, dst)
707
+ }
708
+ else {
709
+ mkdirSync(pathDirname(dst), { recursive: true })
710
+ copyFileSync(src, dst)
711
+ }
712
+ }
713
+ }
714
+
715
+
716
+ // Every consumer refuses an archive past params.archive at acquire, so
717
+ // a publisher refuses to mint one: the reasons, as the forbidden list.
718
+ export function archiveOverCaps(archive: Archive): string[] {
719
+ const over: string[] = []
720
+ if (ARCHIVE_LIMITS.bytes < archive.size) {
721
+ over.push('archive: ' + archive.size + ' bytes, over the cap of ' + ARCHIVE_LIMITS.bytes)
722
+ }
723
+ if (ARCHIVE_LIMITS.files < archive.files.length) {
724
+ over.push('archive: ' + archive.files.length + ' files, over the cap of ' + ARCHIVE_LIMITS.files)
725
+ }
726
+ let total = 0
727
+ for (const f of archive.files) {
728
+ total += f.size
729
+ if (ARCHIVE_LIMITS.fileBytes < f.size) {
730
+ over.push(f.path + ': ' + f.size + ' bytes, over the cap of ' + ARCHIVE_LIMITS.fileBytes)
731
+ }
732
+ }
733
+ if (ARCHIVE_LIMITS.unpacked < total) {
734
+ over.push('archive: unpacks to ' + total + ' bytes, over the cap of ' + ARCHIVE_LIMITS.unpacked)
735
+ }
736
+ return over
737
+ }
738
+
739
+
740
+ const RESERVED_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
741
+ export const REL_PATH_MAX_ELEMENTS = 32
742
+
743
+ // A path inside an archive: forward slashes, the element rules, never
744
+ // absolute, never escaping, never a name one platform reserves.
745
+ export function relPathError(p: string): string | undefined {
746
+ if ('' === p || 512 < p.length || p.startsWith('/') || p.endsWith('/')) {
747
+ return 'an entry path is empty, absolute or a directory'
748
+ }
749
+ const elements = p.split('/')
750
+ if (REL_PATH_MAX_ELEMENTS < elements.length) {
751
+ return 'an entry path has more than ' + REL_PATH_MAX_ELEMENTS + ' elements'
752
+ }
753
+ for (const e of elements) {
754
+ if ('' === e || '.' === e || '..' === e || e.startsWith('.') || e.endsWith('.')) {
755
+ return 'an entry path element is empty or begins or ends with a dot'
756
+ }
757
+ if (!/^[A-Za-z0-9._-]+$/.test(e)) {
758
+ return 'an entry path element is outside the alphabet'
759
+ }
760
+ if (RESERVED_NAME_RE.test(e)) {
761
+ return 'an entry path element is a name a platform reserves'
762
+ }
763
+ }
764
+ return undefined
765
+ }
766
+
767
+
768
+ // `aontu pkg refreeze`: recompute every canon pin and nothing else,
769
+ // which is what a canonical-form change in the engine needs.
770
+ export function pkgRefreeze(root: string, options: PkgToolOptions):
771
+ PkgRefreezeReport {
772
+ const locked = readLock(root)
773
+ const repinned: { key: string, from: string, to: string }[] = []
774
+ const unchanged: string[] = []
775
+ const missing: string[] = []
776
+ const unevaluable: string[] = []
777
+ const lock: LockEntry[] = []
778
+
779
+ for (const key of Object.keys(locked).sort(cmpBytes)) {
780
+ const entry = locked[key]
781
+ const dir = usableKey(key) ?
782
+ storeDir(root, key, entry.canon, entry.pkg ?? key, options, entry.v) : undefined
783
+ const main = undefined === dir ? undefined : pathJoin(dir, mainOf(dir, options))
784
+ if (undefined === main || !existsSync(main)) {
785
+ missing.push(key)
786
+ lock.push(entry)
787
+ continue
788
+ }
789
+ const got = options.eval(readFileSync(main, 'utf8'), main)
790
+ if (!got.ok) {
791
+ unevaluable.push(key)
792
+ lock.push(entry)
793
+ continue
794
+ }
795
+ if (got.hash === entry.canon) {
796
+ unchanged.push(key)
797
+ lock.push(entry)
798
+ continue
799
+ }
800
+ repinned.push({ key, from: entry.canon, to: got.hash })
801
+ lock.push({ ...entry, canon: got.hash })
802
+ }
803
+
804
+ const held = 0 === missing.length && 0 === unevaluable.length
805
+ if (held && 0 < repinned.length) {
806
+ writeLock(root, lock, options)
807
+ }
808
+
809
+ return {
810
+ verdict: held ? 'ok' : 0 < unevaluable.length ? 'error' : 'missing',
811
+ repinned,
812
+ unchanged,
813
+ missing,
814
+ unevaluable,
815
+ }
816
+ }
817
+
818
+
819
+ // `aontu pkg tree`: the locked closure as a graph, each node's edges
820
+ // read from its own package file in the store.
821
+ export function pkgTree(root: string, options: PkgToolOptions): PkgTreeReport {
822
+ const locked = readLock(root)
823
+ const self = packageSelf(root, options)
824
+ const nodes: PkgTreeNode[] = []
825
+ const missing: string[] = []
826
+
827
+ nodes.push({
828
+ key: '' === self.path ? '.' : self.path,
829
+ v: self.version,
830
+ deps: Object.keys(declaredDeps(pathJoin(root, PKG_FILE), options)).sort(cmpBytes),
831
+ })
832
+
833
+ for (const key of Object.keys(locked).sort(cmpBytes)) {
834
+ const entry = locked[key]
835
+ const dir = usableKey(key) ?
836
+ storeDir(root, key, entry.canon, entry.pkg ?? key, options, entry.v) : undefined
837
+ if (undefined === dir) {
838
+ missing.push(key)
839
+ nodes.push({ key, v: entry.v, deps: [] })
840
+ continue
841
+ }
842
+ nodes.push({
843
+ key, v: entry.v,
844
+ deps: Object.keys(declaredDeps(pathJoin(dir, PKG_FILE), options)).sort(cmpBytes),
845
+ })
846
+ }
847
+
848
+ return {
849
+ verdict: 0 === missing.length ? 'ok' : 'missing',
850
+ root: nodes[0].key,
851
+ nodes,
852
+ missing,
853
+ }
854
+ }
855
+
856
+
857
+ export const MANIFEST_SCHEMA = 'aontu-package/v1'
858
+
859
+
860
+ export type ManifestModule = { path: string, main: string, canon: string }
861
+
862
+ // The signed manifest a publish sends (T.Manifest in the specification),
863
+ // less what the act of publishing supplies: the time, and the publisher
864
+ // the token names.
865
+ export type PkgManifest = {
866
+ schema: typeof MANIFEST_SCHEMA
867
+ package: string
868
+ version: string
869
+ publish: 'public' | 'private'
870
+ archive: { format: 'zip', digest: string, size: number, files: ArchiveFile[] }
871
+ modules: ManifestModule[]
872
+ deps: Record<string, Dependency>
873
+ retract?: string[]
874
+ moved?: string
875
+ }
876
+
877
+ export type PkgManifestReport = {
878
+ // `ok` the package may be published; `breaking`, `undecided` or
879
+ // `error` the gate refused, and no publish should follow.
880
+ verdict: 'ok' | 'breaking' | 'undecided' | 'error'
881
+ manifest?: PkgManifest
882
+ // What the package does not declare, sorted. A manifest cannot be
883
+ // minted without them.
884
+ missing: string[]
885
+ forbidden: string[]
886
+ // The gate's findings, when a prior version was named.
887
+ findings: any[]
888
+ }
889
+
890
+
891
+ // `aontu pkg manifest`: the manifest a publish would send, and the gate
892
+ // that decides whether it may be.
893
+ export function pkgManifest(
894
+ root: string, options: PkgToolOptions, against?: string,
895
+ ): PkgManifestReport {
896
+ const self = packageSelf(root, options)
897
+
898
+ const missing: string[] = []
899
+ if ('' === self.path) {
900
+ missing.push('pkg.path')
901
+ }
902
+ else if (isAlias(self.path) || !usableKey(self.path)) {
903
+ missing.push('pkg.path (' + self.path + ' is not a package path)')
904
+ }
905
+ if ('' === self.version) {
906
+ missing.push('pkg.version')
907
+ }
908
+ else if (!VERSION_RE.test(self.version)) {
909
+ missing.push('pkg.version (' + self.version + ' is not MAJOR.MINOR.PATCH)')
910
+ }
911
+ const main = pathJoin(root, self.main)
912
+ if (!existsSync(main) || undefined !== relPathError(self.main)) {
913
+ missing.push(self.main)
914
+ }
915
+
916
+ const refused = (why: string[], forbidden: string[] = []): PkgManifestReport => ({
917
+ verdict: 'error', missing: why.sort(cmpBytes), forbidden, findings: [],
918
+ })
919
+
920
+ if (0 < missing.length) {
921
+ return refused(missing)
922
+ }
923
+
924
+ const newSrc = readFileSync(main, 'utf8')
925
+ const got = options.eval(newSrc, main)
926
+
927
+ // Nothing to pin: `tidy` refuses the same way.
928
+ if (!got.ok) {
929
+ return refused([self.main])
930
+ }
931
+
932
+ const archive = archiveOf(root)
933
+ if (0 < archive.forbidden.length) {
934
+ return refused([], archive.forbidden)
935
+ }
936
+ const over = archiveOverCaps(archive)
937
+ if (0 < over.length) {
938
+ return refused([], over)
939
+ }
940
+
941
+ const report: PkgManifestReport = {
942
+ verdict: 'ok',
943
+ manifest: {
944
+ schema: MANIFEST_SCHEMA,
945
+ package: self.path,
946
+ version: self.version,
947
+ publish: self.publish,
948
+ archive: {
949
+ format: 'zip', digest: archive.digest, size: archive.size,
950
+ files: archive.files,
951
+ },
952
+ modules: [{ path: self.path, main: self.main, canon: got.hash }],
953
+ deps: declaredDeps(pathJoin(root, PKG_FILE), options),
954
+ ...(0 === self.retract.length ? {} : { retract: self.retract }),
955
+ ...(null == self.moved ? {} : { moved: self.moved }),
956
+ },
957
+ missing: [],
958
+ forbidden: [],
959
+ findings: [],
960
+ }
961
+
962
+ if (null == against) {
963
+ return report
964
+ }
965
+
966
+ // THE PUBLISH-TIME COMPATIBILITY GATE: G3's subsumption
967
+ // (ts/src/subsume.ts), wired at the one place versions are minted,
968
+ // with no major to bump past it (ADR-022).
969
+ const prior = packageSelf(against, options)
970
+ const priorMain = pathJoin(against, prior.main)
971
+ if (!existsSync(priorMain)) {
972
+ report.verdict = 'error'
973
+ report.missing = [prior.main]
974
+ return report
975
+ }
976
+
977
+ const priorSrc = readFileSync(priorMain, 'utf8')
978
+ const urls = {
979
+ generalUrl: main,
980
+ specificUrl: priorMain,
981
+ generalPath: main,
982
+ specificPath: priorMain,
983
+ }
984
+ const gate = subsume(newSrc, priorSrc, urls)
985
+
986
+ report.findings = gate.findings
987
+ report.verdict = MANIFEST_VERDICT[gate.verdict]
988
+ if ('error' === gate.verdict) {
989
+ return report
990
+ }
991
+
992
+ // Admission alone is not compatibility: what the prior version
993
+ // generated, the next must generate, and the same (ts/src/compat.ts).
994
+ const outcome = compatOutcome(newSrc, priorSrc, urls)
995
+ report.findings = report.findings.concat(outcome.findings)
996
+ if ('breaking' === outcome.verdict) {
997
+ report.verdict = 'breaking'
998
+ }
999
+ return report
1000
+ }
1001
+
1002
+
1003
+ const MANIFEST_VERDICT: Record<string, PkgManifestReport['verdict']> = {
1004
+ subsumes: 'ok',
1005
+ does_not_subsume: 'breaking',
1006
+ undecided: 'undecided',
1007
+ error: 'error',
1008
+ }
1009
+
1010
+
1011
+ // The manifest as the bytes a publish signs and a repository serves:
1012
+ // canonical aontu, one line, keys sorted, which for scalar leaves is
1013
+ // JSON.
1014
+ export function manifestText(m: PkgManifest, options: PkgToolOptions,
1015
+ extra?: Record<string, any>): string {
1016
+ const src = JSON.stringify({ ...m, ...(extra ?? {}) })
1017
+ return options.eval(src, 'manifest.aon').canon
1018
+ }