@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -0,0 +1,649 @@
1
+ // Read-only planner for a consumer's own dependency closure (`upgrade closure`).
2
+ //
3
+ // It reads the candidate's package.json and package-lock.json, the installed
4
+ // manifest of every lockfile copy of the selected package, the package.json of
5
+ // each directory above the npm root (to refuse a parent that declares
6
+ // workspaces) and, when no package is named, this package's own package.json
7
+ // for the default name. It classifies what an offline reinstall proof would
8
+ // need. It never runs npm, never uses the network, never reads npm
9
+ // configuration or credentials, and never writes. A completed plan is not a
10
+ // proof: `proof` is always 'not-run', and no path here exits 0.
11
+ //
12
+ // Refusals reuse the existing typed codes. `usage` covers an input this plan
13
+ // does not support, `consumer-closure-incomplete` a lockfile entry that an
14
+ // offline reinstall could not be proven for, and `override-not-inherited` a
15
+ // copy the lockfile holds away from the selected package's pin. Each refusal
16
+ // also carries one `reason` from the bounded list in HINTS below.
17
+
18
+ import crypto from 'node:crypto'
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { AtelierDiagnosticError } from '../project/config.mjs'
22
+ import {
23
+ CONSUMER_CLOSURE_INCOMPLETE,
24
+ OVERRIDE_NOT_INHERITED,
25
+ capturedClosure,
26
+ npmTreeLines,
27
+ overrideFindings,
28
+ } from './closure-diagnostics.mjs'
29
+
30
+ const USAGE = 'usage'
31
+
32
+ export const CLOSURE_PLAN_EXIT = 3
33
+ export const CLOSURE_REFUSAL_EXIT = 2
34
+
35
+ const HINTS = {
36
+ 'npm-root-required': 'Pass --npm-root with the absolute path of the staged candidate\'s npm root; this command has no default directory.',
37
+ 'npm-root-not-absolute': 'Pass --npm-root as an absolute path, so the plan cannot follow the current directory.',
38
+ 'npm-root-not-directory': 'Check the path: --npm-root must name an existing directory.',
39
+ 'npm-root-linked': 'Pass the real path of the npm root, given as realPath in the JSON result. npm works from the physical directory, so a path through a link can name a project whose parent directories this plan did not check. On macOS, /tmp is a link into /private and $TMPDIR passes through /var, another such link, so a candidate staged there is refused until its real path is given.',
40
+ 'argument-unsupported': 'This command plans only and accepts --npm-root, --package and --registry-host, each once and each with a value. It does not fetch, install or prove.',
41
+ 'manifest-missing': 'Point --npm-root at the directory that holds the candidate\'s own package.json.',
42
+ 'lockfile-missing': 'Install the candidate with npm so it has its own package-lock.json, then plan again.',
43
+ 'lockfile-invalid': 'Regenerate the candidate\'s package-lock.json with npm; this plan reads it as a JSON object whose packages map holds one object per entry.',
44
+ 'lockfile-version': 'Regenerate the lockfile with a current npm (lockfileVersion 2 or 3); version 1 has no packages map to classify.',
45
+ workspaces: 'npm workspaces are not supported by this plan. Check a single npm root project, or record the workspace as unchecked.',
46
+ 'prefix-mismatch': 'A parent directory declares npm workspaces. If its workspaces include this directory, npm treats that parent as the root project; this plan does not resolve workspace membership, so it refuses any such parent. Stage the candidate where no parent directory declares workspaces.',
47
+ 'linked-node-modules': 'The candidate needs its own node_modules directory; a link shares an installation with another project.',
48
+ 'missing-integrity': 'An offline reinstall needs every registry entry locked with both resolved and integrity; inspect how the candidate resolved these entries.',
49
+ 'git-source': 'A git dependency cannot be captured from the registry. Record it as unchecked, or replace it with a published version before proving the closure.',
50
+ 'other-host': 'This tarball is on a host that was not named as an approved registry. Name the host with --registry-host only if the owner approves it.',
51
+ 'outside-project': 'This entry points outside the candidate. Record it as unchecked, or bring the dependency inside the project or onto the registry.',
52
+ 'link-target-missing': 'A link entry must name the folder it resolves to. Regenerate the lockfile with npm, or record the linked dependency as unchecked.',
53
+ 'zero-copies': 'The lockfile lists no copy of a package it must hold, so nothing was checked. Reinstall the candidate and confirm the lockfile is the candidate\'s own.',
54
+ 'selected-package-unreadable': 'Install the selected package in the candidate, so its manifest and overrides can be read from the candidate\'s own node_modules.',
55
+ 'selected-package-mismatch': 'The installed manifest is not the package the lockfile records at that path, so its overrides were not used. Reinstall the candidate from its own lockfile, then plan again.',
56
+ 'selected-copies-differ': 'The lockfile installs the selected package more than once and the copies do not agree, so no single set of overrides applies. Deduplicate the selected package, or record its overrides as unchecked.',
57
+ 'copy-off-pin': 'npm applies overrides only in the root project, so consumers never receive them. Changing a pin or an override in the owner\'s project needs the owner\'s consent.',
58
+ }
59
+
60
+ const HOSTLESS_REGISTRY_HINT = 'This tarball URL has no readable host, so no --registry-host can approve it. Regenerate the lockfile with npm so the entry records a full registry URL, or record it as unchecked.'
61
+
62
+ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
63
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
64
+
65
+ // A parsed JSON object, or the reason it could not be read.
66
+ function readJsonObject(file) {
67
+ let bytes
68
+ try {
69
+ bytes = fs.readFileSync(file)
70
+ } catch {
71
+ return { missing: true }
72
+ }
73
+ try {
74
+ const value = JSON.parse(bytes.toString('utf8'))
75
+ return isObject(value) ? { value, bytes } : { invalid: true }
76
+ } catch {
77
+ return { invalid: true }
78
+ }
79
+ }
80
+
81
+ // Lexical containment: `target` resolved against the npm root stays inside it.
82
+ // The target need not exist, so links are not followed here.
83
+ function insideProject(npmRoot, target) {
84
+ const relative = path.relative(npmRoot, path.resolve(npmRoot, target))
85
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
86
+ }
87
+
88
+ // A lockfile path under a node_modules directory names an installed package;
89
+ // any other path is a folder of the project itself, the target of a link.
90
+ const installedPath = (entryPath) => /(?:^|\/)node_modules\//.test(entryPath)
91
+
92
+ const declaresWorkspaces = (manifest) => {
93
+ const workspaces = manifest?.workspaces
94
+ if (Array.isArray(workspaces)) return workspaces.length > 0
95
+ return isObject(workspaces)
96
+ }
97
+
98
+ // The nearest parent directory whose package.json declares workspaces, or null.
99
+ function workspaceAncestor(npmRoot) {
100
+ let current = path.dirname(npmRoot)
101
+ for (;;) {
102
+ const manifest = readJsonObject(path.join(current, 'package.json'))
103
+ if (manifest.value && declaresWorkspaces(manifest.value)) return current
104
+ const parent = path.dirname(current)
105
+ if (parent === current) return null
106
+ current = parent
107
+ }
108
+ }
109
+
110
+ // node_modules must be the candidate's own directory: absent is allowed here,
111
+ // a link or a directory that resolves elsewhere is not.
112
+ function nodeModulesIsLinked(npmRoot) {
113
+ const own = path.join(npmRoot, 'node_modules')
114
+ let stat
115
+ try {
116
+ stat = fs.lstatSync(own)
117
+ } catch {
118
+ return false
119
+ }
120
+ if (stat.isSymbolicLink()) return true
121
+ try {
122
+ return fs.realpathSync(own) !== path.join(fs.realpathSync(npmRoot), 'node_modules')
123
+ } catch {
124
+ return true
125
+ }
126
+ }
127
+
128
+ // npm's rule for one platform field (as in npm-install-checks): the list must
129
+ // match none of its negated values and, when it has plain values, at least one
130
+ // of them. `any` alone allows everything.
131
+ function listAllows(value, list) {
132
+ const values = (Array.isArray(list) ? list : [list]).filter((item) => typeof item === 'string')
133
+ if (values.length === 0) return true
134
+ if (values.length === 1 && values[0] === 'any') return true
135
+ let negated = 0
136
+ let matched = false
137
+ for (const item of values) {
138
+ if (item.startsWith('!')) {
139
+ negated += 1
140
+ if (item.slice(1) === value) return false
141
+ } else if (item === value) {
142
+ matched = true
143
+ }
144
+ }
145
+ return matched || negated === values.length
146
+ }
147
+
148
+ // The lock field that provably excludes this host, or null. Only evidence
149
+ // counts: an unknown host value never excludes, and `libc` is compared only on
150
+ // a Linux host whose libc is known.
151
+ function platformExclusion(entry, host) {
152
+ if (entry?.os !== undefined && typeof host?.os === 'string' && !listAllows(host.os, entry.os)) return 'os'
153
+ if (entry?.cpu !== undefined && typeof host?.cpu === 'string' && !listAllows(host.cpu, entry.cpu)) return 'cpu'
154
+ if (entry?.libc !== undefined && host?.os === 'linux' && typeof host?.libc === 'string' && !listAllows(host.libc, entry.libc)) return 'libc'
155
+ return null
156
+ }
157
+
158
+ // Where each lockfile entry comes from, with the three source kinds
159
+ // `inspectPackageProvenance` already uses (`npm`, `git`, `local_path`), plus
160
+ // links and bundled entries. Unlike `capturedClosure`, which skips what it
161
+ // cannot cache, this names every entry an offline proof could not capture from
162
+ // an approved registry:
163
+ // - `git-source`: a git dependency;
164
+ // - `other-host`: a tarball on a host outside `approvedRegistryHosts`, when
165
+ // the caller names approved hosts;
166
+ // - `outside-project`: a link, folder or local file outside the npm root, or a
167
+ // source with a scheme this plan does not know;
168
+ // - `link-target-missing`: a link with no folder named in `resolved`.
169
+ // An installed entry with no `resolved` is counted as `unclassified`, so the
170
+ // counts always add up to the number of entries; refusing it is left to
171
+ // `capturedClosure`. `registrySchemes` counts http and https separately:
172
+ // approving a host says nothing about the transport. A registry URL that does
173
+ // not parse is counted under `unparseable` and, when approved hosts are named,
174
+ // is always a finding: no approved host can match it.
175
+ export function lockSourceFindings(lock, { npmRoot, approvedRegistryHosts = null } = {}) {
176
+ const counts = { npm: 0, git: 0, local_path: 0, link: 0, bundled: 0, unclassified: 0 }
177
+ const registryHosts = {}
178
+ const registrySchemes = {}
179
+ const findings = []
180
+ const approved = Array.isArray(approvedRegistryHosts) && approvedRegistryHosts.length > 0 ? new Set(approvedRegistryHosts) : null
181
+ const entries = Object.entries(lock?.packages ?? {}).sort(([left], [right]) => left.localeCompare(right))
182
+ for (const [entryPath, entry] of entries) {
183
+ if (entryPath === '') continue
184
+ if (entry?.inBundle) {
185
+ counts.bundled += 1
186
+ continue
187
+ }
188
+ const resolved = typeof entry?.resolved === 'string' ? entry.resolved : null
189
+ if (entry?.link) {
190
+ counts.link += 1
191
+ if (resolved === null || resolved.trim() === '') findings.push({ reason: 'link-target-missing', path: entryPath })
192
+ else if (!insideProject(npmRoot, resolved)) findings.push({ reason: 'outside-project', path: entryPath })
193
+ continue
194
+ }
195
+ if (!installedPath(entryPath)) {
196
+ counts.local_path += 1
197
+ if (!insideProject(npmRoot, entryPath)) findings.push({ reason: 'outside-project', path: entryPath })
198
+ continue
199
+ }
200
+ if (resolved === null) {
201
+ counts.unclassified += 1
202
+ continue
203
+ }
204
+ if (/^(?:git\+|git:)/.test(resolved)) {
205
+ counts.git += 1
206
+ findings.push({ reason: 'git-source', path: entryPath })
207
+ continue
208
+ }
209
+ if (/^https?:\/\//.test(resolved)) {
210
+ counts.npm += 1
211
+ let host = null
212
+ try {
213
+ host = new URL(resolved).host
214
+ } catch {
215
+ host = null
216
+ }
217
+ const scheme = resolved.slice(0, resolved.indexOf(':'))
218
+ registrySchemes[scheme] = (registrySchemes[scheme] ?? 0) + 1
219
+ // An empty host is as unusable as a URL that does not parse.
220
+ const key = host || 'unparseable'
221
+ registryHosts[key] = (registryHosts[key] ?? 0) + 1
222
+ if (approved && (!host || !approved.has(host))) findings.push({ reason: 'other-host', path: entryPath, host: host || null })
223
+ continue
224
+ }
225
+ counts.local_path += 1
226
+ const local = resolved.startsWith('file:') ? resolved.slice('file:'.length) : null
227
+ if (local === null || /^[a-z][a-z0-9+.-]*:\/\//i.test(resolved) || !insideProject(npmRoot, local)) {
228
+ findings.push({ reason: 'outside-project', path: entryPath })
229
+ }
230
+ }
231
+ return { counts, registryHosts, registrySchemes, findings }
232
+ }
233
+
234
+ // The names that link entries install: a link lives at a node_modules path and
235
+ // carries no version of its own, so an override for such a name cannot be
236
+ // compared by this plan.
237
+ function linkedNames(lock) {
238
+ const names = new Set()
239
+ for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) {
240
+ if (entryPath === '' || !entry?.link || !installedPath(entryPath)) continue
241
+ names.add(entryPath.slice(entryPath.lastIndexOf('node_modules/') + 'node_modules/'.length))
242
+ }
243
+ return names
244
+ }
245
+
246
+ // Every copy of `name` the lockfile installs, by the same rule the override
247
+ // classifier uses: one per node_modules path, named by the entry's own `name`
248
+ // (an npm: alias) or else by its path; links are not copies.
249
+ function lockCopies(lock, name) {
250
+ return Object.entries(lock?.packages ?? {})
251
+ .filter(([entryPath, entry]) => entryPath !== '' && !entry?.link && installedPath(entryPath)
252
+ && (entry?.name ?? entryPath.slice(entryPath.lastIndexOf('node_modules/') + 'node_modules/'.length)) === name)
253
+ .map(([entryPath, entry]) => ({ path: entryPath, version: entry?.version ?? null, dependencies: Object.keys(entry?.dependencies ?? {}) }))
254
+ .sort((left, right) => left.path.localeCompare(right.path))
255
+ }
256
+
257
+ // `overrideFindings` compares unconditional exact pins only and throws on
258
+ // anything else. Each override is offered to it alone first, so what is later
259
+ // compared is exactly what that classifier accepts, and everything else is
260
+ // listed as unchecked instead of being silently skipped.
261
+ function splitOverrides(overrides) {
262
+ const exact = {}
263
+ const unchecked = []
264
+ for (const [name, value] of Object.entries(isObject(overrides) ? overrides : {})) {
265
+ try {
266
+ overrideFindings({ publisherOverrides: { [name]: value }, consumerLock: { packages: {} } })
267
+ exact[name] = value
268
+ } catch (error) {
269
+ if (!(error instanceof TypeError)) throw error
270
+ const reason = isObject(value) ? 'nested' : /selects versions/.test(error.message) ? 'selector-key' : 'non-exact'
271
+ unchecked.push({ name, reason })
272
+ }
273
+ }
274
+ return { exact, unchecked }
275
+ }
276
+
277
+ // The lockfile path npm's resolution gives a dependency named by its ancestry
278
+ // (`a > b` is b as required by a): the nearest node_modules holding it, from
279
+ // the requiring package outwards. Null when the lockfile has no such entry.
280
+ function lockPathFor(lock, names) {
281
+ const packages = lock?.packages ?? {}
282
+ let current = ''
283
+ for (const name of names) {
284
+ let base = current
285
+ let found = null
286
+ for (;;) {
287
+ const candidate = `${base ? `${base}/` : ''}node_modules/${name}`
288
+ if (Object.hasOwn(packages, candidate)) {
289
+ found = candidate
290
+ break
291
+ }
292
+ if (base === '') break
293
+ const cut = base.lastIndexOf('/node_modules/')
294
+ base = cut === -1 ? '' : base.slice(0, cut)
295
+ }
296
+ if (found === null) return null
297
+ const entry = packages[found]
298
+ current = entry?.link && typeof entry.resolved === 'string' ? entry.resolved : found
299
+ }
300
+ return current
301
+ }
302
+
303
+ // One entry per dependency path in an `npm ls --all --json` tree, keyed by the
304
+ // names along the path. It is built from `npmTreeLines`, so a package with no
305
+ // installed version is read the same way the captured-closure check reads it.
306
+ function treeVersions(npmLsJson) {
307
+ const versions = new Map()
308
+ for (const line of npmTreeLines(npmLsJson)) {
309
+ const names = []
310
+ let version = null
311
+ for (const segment of line.split(' > ')) {
312
+ const at = segment.lastIndexOf('@')
313
+ names.push(segment.slice(0, at))
314
+ version = segment.slice(at + 1)
315
+ }
316
+ versions.set(names.join(' > '), { names, version: version === 'missing' ? null : version })
317
+ }
318
+ return versions
319
+ }
320
+
321
+ // The differences between the tree the lockfile describes (`npm ls --all
322
+ // --json --package-lock-only`) and the installed tree (`npm ls --all --json`),
323
+ // by dependency path and version, each classified on evidence:
324
+ // - `stale`: two different versions at one dependency path;
325
+ // - `otherPlatform`: no installed version, and the lockfile entry's `os`,
326
+ // `cpu` or `libc` excludes this host;
327
+ // - `missing`: no installed version and no such evidence;
328
+ // - `extraneous`: installed, and not in the lockfile's tree.
329
+ // `agrees` is true only when both trees report no problems and nothing is
330
+ // stale, missing or extraneous. Both trees are passed in; nothing runs npm.
331
+ export function lockTreeDifferences({ lock, lockTree, installedTree, host } = {}) {
332
+ const problems = [...(lockTree?.problems ?? []), ...(installedTree?.problems ?? [])].map(String)
333
+ const locked = treeVersions(lockTree)
334
+ const installed = treeVersions(installedTree)
335
+ const stale = []
336
+ const otherPlatform = []
337
+ const missing = []
338
+ const extraneous = []
339
+ for (const [dependencyPath, want] of locked) {
340
+ const have = installed.get(dependencyPath)
341
+ if (have && have.version !== null) {
342
+ if (have.version !== want.version) stale.push({ dependencyPath, locked: want.version, installed: have.version })
343
+ continue
344
+ }
345
+ const lockPath = lockPathFor(lock, want.names)
346
+ const excludedBy = lockPath === null ? null : platformExclusion(lock.packages[lockPath], host)
347
+ if (excludedBy) otherPlatform.push({ dependencyPath, lockPath, excludedBy })
348
+ else missing.push({ dependencyPath, lockPath })
349
+ }
350
+ for (const [dependencyPath, have] of installed) {
351
+ if (!locked.has(dependencyPath)) extraneous.push({ dependencyPath, installed: have.version })
352
+ }
353
+ return {
354
+ agrees: problems.length === 0 && stale.length === 0 && missing.length === 0 && extraneous.length === 0,
355
+ problems,
356
+ stale,
357
+ otherPlatform,
358
+ missing,
359
+ extraneous,
360
+ }
361
+ }
362
+
363
+ // This package's own name: the default selected package of a guided upgrade.
364
+ function ownPackageName() {
365
+ return JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).name
366
+ }
367
+
368
+ // The plan for one explicit npm root. It never throws for a classified case:
369
+ // every refusal is an entry in `refusals`, with the typed code, one bounded
370
+ // reason, a message and a next step. `status` is 'planned' or 'refused';
371
+ // `proof` is always 'not-run'.
372
+ //
373
+ // - `npmRoot`: absolute path of the staged candidate's npm root. Required.
374
+ // - `selectedPackage`: the package whose exact overrides are checked against
375
+ // the candidate's lockfile. Defaults to this package.
376
+ // - `approvedRegistryHosts`: when given, a registry tarball on any other host
377
+ // is refused. When absent, hosts are reported and left unverified, because
378
+ // this plan does not read npm configuration.
379
+ // - `host`: `{ os, cpu, libc }` for the platform evidence. Defaults to this
380
+ // process, with libc unknown.
381
+ export function planConsumerClosure({ npmRoot, selectedPackage = null, approvedRegistryHosts = null, host = null } = {}) {
382
+ const plan = {
383
+ command: 'upgrade closure',
384
+ proof: 'not-run',
385
+ status: 'refused',
386
+ npmRoot: typeof npmRoot === 'string' ? npmRoot : null,
387
+ prefixVerified: false,
388
+ approvedRegistryVerified: false,
389
+ refusals: [],
390
+ }
391
+ const refuse = (code, reason, message, detail = {}) => {
392
+ plan.refusals.push({ code, reason, message, hint: HINTS[reason], ...detail })
393
+ return plan
394
+ }
395
+
396
+ if (typeof npmRoot !== 'string' || npmRoot.trim() === '') {
397
+ return refuse(USAGE, 'npm-root-required', '--npm-root is required: name the staged candidate\'s npm root explicitly')
398
+ }
399
+ if (!path.isAbsolute(npmRoot)) return refuse(USAGE, 'npm-root-not-absolute', '--npm-root must be an absolute path')
400
+ const root = path.resolve(npmRoot)
401
+ plan.npmRoot = root
402
+ let rootIsDirectory = false
403
+ try {
404
+ rootIsDirectory = fs.statSync(root).isDirectory()
405
+ } catch {
406
+ rootIsDirectory = false
407
+ }
408
+ if (!rootIsDirectory) return refuse(USAGE, 'npm-root-not-directory', '--npm-root does not name an existing directory')
409
+ // npm works from the physical directory. A path through a link would have
410
+ // its own parents checked for workspaces here, not the parents npm sees.
411
+ let realRoot = null
412
+ try {
413
+ realRoot = fs.realpathSync(root)
414
+ } catch {
415
+ realRoot = null
416
+ }
417
+ if (realRoot !== root) {
418
+ return refuse(USAGE, 'npm-root-linked', '--npm-root is not the real path of the directory: it is a link or passes through one',
419
+ realRoot === null ? {} : { realPath: realRoot })
420
+ }
421
+ if (selectedPackage !== null && selectedPackage !== undefined && (typeof selectedPackage !== 'string' || selectedPackage.trim() === '')) {
422
+ return refuse(USAGE, 'argument-unsupported', '--package needs a package name', { argument: '--package' })
423
+ }
424
+ if (approvedRegistryHosts !== null && approvedRegistryHosts !== undefined
425
+ && (!Array.isArray(approvedRegistryHosts) || approvedRegistryHosts.length === 0
426
+ || approvedRegistryHosts.some((item) => typeof item !== 'string' || item.trim() === ''))) {
427
+ return refuse(USAGE, 'argument-unsupported', '--registry-host needs at least one host name', { argument: '--registry-host' })
428
+ }
429
+
430
+ const manifest = readJsonObject(path.join(root, 'package.json'))
431
+ if (!manifest.value) return refuse(USAGE, 'manifest-missing', 'the npm root has no readable package.json')
432
+ if (declaresWorkspaces(manifest.value)) return refuse(USAGE, 'workspaces', 'the npm root declares npm workspaces, which this plan does not support')
433
+ if (workspaceAncestor(root) !== null) {
434
+ return refuse(USAGE, 'prefix-mismatch', 'a parent directory declares npm workspaces, so npm may not treat this directory as its own root project')
435
+ }
436
+ if (nodeModulesIsLinked(root)) {
437
+ return refuse(USAGE, 'linked-node-modules', 'node_modules in the npm root is a link or resolves inside another installation')
438
+ }
439
+
440
+ const lockFile = readJsonObject(path.join(root, 'package-lock.json'))
441
+ if (lockFile.missing) return refuse(USAGE, 'lockfile-missing', 'the npm root has no package-lock.json')
442
+ if (lockFile.invalid) return refuse(USAGE, 'lockfile-invalid', 'package-lock.json in the npm root is not a JSON object')
443
+ const lock = lockFile.value
444
+ if (![2, 3].includes(lock.lockfileVersion) || !isObject(lock.packages)) {
445
+ return refuse(USAGE, 'lockfile-version', 'package-lock.json must be lockfileVersion 2 or 3 with a packages map')
446
+ }
447
+ // Every classifier below iterates the packages map, so its shape is checked
448
+ // once here and a malformed entry is a typed refusal, never a TypeError.
449
+ const malformed = Object.keys(lock.packages).filter((entryPath) => !isObject(lock.packages[entryPath])).sort((left, right) => left.localeCompare(right))
450
+ if (malformed.length > 0) {
451
+ return refuse(USAGE, 'lockfile-invalid', `package-lock.json records ${JSON.stringify(malformed[0])} as something other than a package entry`,
452
+ { path: malformed[0], malformedEntries: malformed.length })
453
+ }
454
+ if (declaresWorkspaces(lock.packages[''])) {
455
+ return refuse(USAGE, 'workspaces', 'the lockfile records npm workspaces, which this plan does not support')
456
+ }
457
+
458
+ plan.lock = {
459
+ file: 'package-lock.json',
460
+ sha256: sha256(lockFile.bytes),
461
+ lockfileVersion: lock.lockfileVersion,
462
+ entries: Object.keys(lock.packages).filter((entryPath) => entryPath !== '').length,
463
+ }
464
+ plan.host = { os: host?.os ?? process.platform, cpu: host?.cpu ?? process.arch, libc: host?.libc ?? null }
465
+
466
+ const sources = lockSourceFindings(lock, { npmRoot: root, approvedRegistryHosts })
467
+ plan.sources = { counts: sources.counts, registryHosts: sources.registryHosts, registrySchemes: sources.registrySchemes }
468
+ plan.approvedRegistryVerified = Array.isArray(approvedRegistryHosts) && approvedRegistryHosts.length > 0
469
+ const sourceMessages = {
470
+ 'git-source': (finding) => `the lockfile records ${finding.path} from a git source, which an offline reinstall from the registry cannot capture`,
471
+ 'other-host': (finding) => (finding.host === null
472
+ ? `the lockfile records ${finding.path} from a registry URL with no readable host, which no approved registry host can match`
473
+ : `the lockfile records ${finding.path} from ${finding.host}, which is not an approved registry host`),
474
+ 'link-target-missing': (finding) => `the lockfile records ${finding.path} as a link without the folder it resolves to`,
475
+ 'outside-project': (finding) => `the lockfile records ${finding.path} from a source outside the npm root`,
476
+ }
477
+ for (const finding of sources.findings) {
478
+ const { reason, ...detail } = finding
479
+ // Naming a host cannot approve a URL that has none, so that case gets its own next step.
480
+ const hint = reason === 'other-host' && finding.host === null ? { hint: HOSTLESS_REGISTRY_HINT } : {}
481
+ refuse(CONSUMER_CLOSURE_INCOMPLETE, reason, sourceMessages[reason](finding), { ...detail, ...hint })
482
+ }
483
+
484
+ const { tarballs, missing } = capturedClosure(lock)
485
+ plan.capture = { registryTarballs: tarballs.length }
486
+ for (const entryPath of missing.filter(installedPath)) {
487
+ refuse(CONSUMER_CLOSURE_INCOMPLETE, 'missing-integrity',
488
+ `the lockfile records ${entryPath} without a registry resolved URL or integrity`, { path: entryPath })
489
+ }
490
+
491
+ plan.otherPlatform = Object.entries(lock.packages)
492
+ .filter(([entryPath, entry]) => entryPath !== '' && installedPath(entryPath) && !entry?.link)
493
+ .map(([entryPath, entry]) => ({ path: entryPath, optional: entry?.optional === true, excludedBy: platformExclusion(entry, plan.host) }))
494
+ .filter((item) => item.excludedBy !== null)
495
+ .sort((left, right) => left.path.localeCompare(right.path))
496
+
497
+ const selected = typeof selectedPackage === 'string' ? selectedPackage.trim() : ownPackageName()
498
+ const copies = lockCopies(lock, selected)
499
+ // `manifests` names every installed manifest this plan read for the selected
500
+ // package, with the identity it declares, so a reader can see what the
501
+ // override check was based on.
502
+ plan.selectedPackage = { name: selected, copies: copies.map(({ path: copyPath, version }) => ({ path: copyPath, version })), manifests: [] }
503
+ plan.overrides = { checked: [], notInstalled: [], unchecked: [], multipleCopiesAtPin: [] }
504
+ if (copies.length === 0) {
505
+ refuse(CONSUMER_CLOSURE_INCOMPLETE, 'zero-copies',
506
+ `the lockfile lists no copy of the selected package ${selected}, so its overrides were not checked`, { package: selected })
507
+ } else {
508
+ // Each copy's manifest is bound to its own lockfile entry by name and
509
+ // version. Overrides are used only when every copy is bound and all copies
510
+ // agree; a refused or unreadable copy contributes nothing.
511
+ const bound = []
512
+ for (const copy of copies) {
513
+ const manifestPath = `${copy.path}/package.json`
514
+ const installedManifest = insideProject(root, copy.path)
515
+ ? readJsonObject(path.join(root, copy.path, 'package.json'))
516
+ : { missing: true }
517
+ if (!installedManifest.value) {
518
+ refuse(CONSUMER_CLOSURE_INCOMPLETE, 'selected-package-unreadable',
519
+ `the manifest of the selected package ${selected} cannot be read at ${copy.path}, so its overrides were not checked`,
520
+ { package: selected, path: copy.path })
521
+ continue
522
+ }
523
+ const declared = {
524
+ name: typeof installedManifest.value.name === 'string' ? installedManifest.value.name : null,
525
+ version: typeof installedManifest.value.version === 'string' ? installedManifest.value.version : null,
526
+ }
527
+ plan.selectedPackage.manifests.push({ path: manifestPath, ...declared, sha256: sha256(installedManifest.bytes) })
528
+ if (declared.name !== selected || copy.version === null || declared.version !== copy.version) {
529
+ refuse(CONSUMER_CLOSURE_INCOMPLETE, 'selected-package-mismatch',
530
+ `the manifest at ${manifestPath} declares ${declared.name ?? 'no name'}@${declared.version ?? 'no version'}, but the lockfile records ${selected}@${copy.version ?? 'no version'} there, so its overrides were not used`,
531
+ { package: selected, path: copy.path, locked: { name: selected, version: copy.version }, manifest: declared })
532
+ continue
533
+ }
534
+ bound.push({ copy, overrides: installedManifest.value.overrides })
535
+ }
536
+ const lockedVersions = [...new Set(copies.map((copy) => String(copy.version)))]
537
+ const overrideSets = [...new Set(bound.map((item) => JSON.stringify(item.overrides ?? null)))]
538
+ if (lockedVersions.length > 1 || (bound.length === copies.length && overrideSets.length > 1)) {
539
+ refuse(CONSUMER_CLOSURE_INCOMPLETE, 'selected-copies-differ',
540
+ `the lockfile installs the selected package ${selected} at ${copies.map((copy) => `${copy.path}@${copy.version}`).join(', ')}, and the copies do not agree, so its overrides were not checked`,
541
+ { package: selected, copies: plan.selectedPackage.copies })
542
+ } else if (bound.length === copies.length) {
543
+ const { exact, unchecked } = splitOverrides(bound[0].overrides)
544
+ plan.overrides.unchecked = unchecked
545
+ const { findings, compared } = overrideFindings({ publisherOverrides: exact, consumerLock: lock })
546
+ const required = new Set(copies.flatMap((copy) => copy.dependencies))
547
+ const linked = linkedNames(lock)
548
+ const offPin = new Set()
549
+ for (const finding of findings) {
550
+ const away = finding.found.filter((copy) => copy.version !== finding.pinned)
551
+ if (away.length === 0) {
552
+ // More than one copy, all at the pin: valid in an owner's tree.
553
+ plan.overrides.multipleCopiesAtPin.push({ package: finding.package, pinned: finding.pinned, found: finding.found })
554
+ continue
555
+ }
556
+ offPin.add(finding.package)
557
+ refuse(OVERRIDE_NOT_INHERITED, 'copy-off-pin',
558
+ `${finding.package} is pinned to ${finding.pinned} by the selected package's override, but the lockfile installs ${finding.found.map((copy) => `${copy.path}@${copy.version}`).join(', ')}`,
559
+ { package: finding.package, pinned: finding.pinned, found: finding.found })
560
+ }
561
+ for (const name of Object.keys(exact)) {
562
+ if (linked.has(name)) {
563
+ // A link provides this package and the plan does not read the linked
564
+ // folder's version: never checked and never "not installed". An
565
+ // off-pin registry copy beside the link is still refused above.
566
+ plan.overrides.unchecked.push({ name, reason: compared.includes(name) ? 'linked-copy' : 'link-only' })
567
+ } else if (compared.includes(name)) {
568
+ if (!offPin.has(name)) plan.overrides.checked.push(name)
569
+ } else if (required.has(name)) {
570
+ // The selected package depends on it, yet the lockfile holds no copy:
571
+ // a failed listing, never a pass.
572
+ refuse(CONSUMER_CLOSURE_INCOMPLETE, 'zero-copies',
573
+ `the selected package ${selected} depends on ${name}, but the lockfile lists no copy of it`, { package: name })
574
+ } else {
575
+ plan.overrides.notInstalled.push(name)
576
+ }
577
+ }
578
+ }
579
+ }
580
+
581
+ plan.status = plan.refusals.length === 0 ? 'planned' : 'refused'
582
+ return plan
583
+ }
584
+
585
+ // The plan's first refusal as the typed error the CLI prints, or null.
586
+ export function closureRefusalError(plan) {
587
+ const [first, ...rest] = plan?.refusals ?? []
588
+ if (!first) return null
589
+ const more = rest.length > 0 ? ` (and ${rest.length} more; see the JSON result)` : ''
590
+ return new AtelierDiagnosticError(first.code, `${first.message}${more}`, { hint: first.hint, exitCode: CLOSURE_REFUSAL_EXIT })
591
+ }
592
+
593
+ const CLOSURE_FLAGS = ['npm-root', 'package', 'registry-host']
594
+
595
+ // How often each closure flag appears in the raw arguments, in either
596
+ // spelling (`--flag value` or `--flag=value`). The shared parser keeps only
597
+ // the last value of a repeated flag, so repeats are counted here.
598
+ function repeatedFlags(argv) {
599
+ return CLOSURE_FLAGS.filter((flag) => argv.filter((item) => item === `--${flag}` || (typeof item === 'string' && item.startsWith(`--${flag}=`))).length > 1)
600
+ }
601
+
602
+ // `atelier upgrade closure --npm-root <absolute-dir>`. Prints exactly one JSON
603
+ // plan, then exits 3 for a completed plan or throws the typed refusal (exit
604
+ // 2). An argument this command does not accept is a refused plan too, so
605
+ // every run prints one document. It resolves no Atelier project and writes
606
+ // nothing. `argv` is the raw argument list, used only to refuse a flag given
607
+ // more than once.
608
+ export function runClosurePlanCommand(args, argv = []) {
609
+ // The raw arguments are checked too: the shared parser drops some names
610
+ // (for example --__proto__), which would otherwise pass unnoticed.
611
+ const rawFlags = (Array.isArray(argv) ? argv : [])
612
+ .filter((item) => typeof item === 'string' && item.startsWith('--'))
613
+ .map((item) => item.slice(2).split('=')[0])
614
+ const unsupported = [...new Set([
615
+ ...Object.keys(args).filter((key) => key !== '_' && !CLOSURE_FLAGS.includes(key)).map((key) => `--${key}`),
616
+ ...rawFlags.filter((name) => !CLOSURE_FLAGS.includes(name)).map((name) => `--${name}`),
617
+ ]), ...(args._ ?? []).slice(1).map((item) => (item === '' ? '(empty argument)' : item))]
618
+ const valueless = CLOSURE_FLAGS.filter((flag) => args[flag] === true)
619
+ const repeated = repeatedFlags(Array.isArray(argv) ? argv : [])
620
+ const hosts = typeof args['registry-host'] === 'string'
621
+ ? args['registry-host'].split(',').map((item) => item.trim()).filter(Boolean)
622
+ : null
623
+ let argumentProblem = null
624
+ if (unsupported.length > 0) argumentProblem = [`upgrade closure does not accept ${unsupported.join(', ')}`, unsupported]
625
+ else if (valueless.length > 0) argumentProblem = [`${valueless.map((flag) => `--${flag}`).join(', ')} needs a value`, valueless.map((flag) => `--${flag}`)]
626
+ else if (repeated.length > 0) argumentProblem = [`${repeated.map((flag) => `--${flag}`).join(', ')} was given more than once`, repeated.map((flag) => `--${flag}`)]
627
+ else if (typeof args.package === 'string' && args.package.trim() === '') argumentProblem = ['--package needs a package name', ['--package']]
628
+ else if (hosts !== null && hosts.length === 0) argumentProblem = ['--registry-host needs at least one host name', ['--registry-host']]
629
+
630
+ const plan = argumentProblem
631
+ ? {
632
+ command: 'upgrade closure',
633
+ proof: 'not-run',
634
+ status: 'refused',
635
+ npmRoot: typeof args['npm-root'] === 'string' ? args['npm-root'] : null,
636
+ prefixVerified: false,
637
+ approvedRegistryVerified: false,
638
+ refusals: [{ code: USAGE, reason: 'argument-unsupported', message: argumentProblem[0], hint: HINTS['argument-unsupported'], arguments: argumentProblem[1] }],
639
+ }
640
+ : planConsumerClosure({
641
+ npmRoot: typeof args['npm-root'] === 'string' ? args['npm-root'] : null,
642
+ selectedPackage: typeof args.package === 'string' ? args.package : null,
643
+ approvedRegistryHosts: hosts,
644
+ })
645
+ console.log(JSON.stringify(plan, null, 2))
646
+ const refusal = closureRefusalError(plan)
647
+ if (refusal) throw refusal
648
+ process.exitCode = CLOSURE_PLAN_EXIT
649
+ }