@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.14
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.
- package/CHANGELOG.md +233 -0
- package/README.md +22 -5
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/capability-stewardship.md +2 -4
- package/docs/ingestion.md +3 -3
- package/docs/inquiry-harnesses.md +2 -3
- package/docs/install.md +3 -3
- package/docs/learning-harnesses.md +2 -3
- package/docs/local-services.md +42 -3
- package/docs/obsidian-contract.md +23 -0
- package/docs/obsidian-plugin.md +214 -3
- package/docs/obsidian.md +271 -11
- package/docs/personal-workspace.md +13 -4
- package/docs/release-engineering.md +2 -1
- package/docs/repo-boundary-guard.md +26 -3
- package/docs/semantic-operation.md +4 -3
- package/docs/upgrade.md +174 -2
- package/fixtures/templates/local-library.v1.json +2 -1
- package/package.json +2 -1
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/src/boundary/content-rules.mjs +532 -67
- package/src/boundary/policy.mjs +135 -56
- package/src/cli/run.mjs +3 -2
- package/src/commands/obsidian.mjs +23 -7
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +19 -1
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +17 -1
- package/src/project/path-match.mjs +2 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
- package/src/projection/obsidian/personal-workspace.mjs +87 -4
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +72 -5
- package/src/projection/project.mjs +3 -2
- package/src/runtime/obsidian/engine.mjs +293 -55
- package/src/runtime/obsidian/lifecycle.mjs +17 -12
- package/src/runtime/obsidian/login-item.mjs +4 -1
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +3 -2
- package/src/runtime/obsidian/personal-selection.mjs +125 -0
- package/src/runtime/obsidian/pipeline.mjs +44 -3
- package/src/runtime/obsidian/private-lock.mjs +39 -4
- package/src/runtime/obsidian/service-main.mjs +58 -12
- package/src/runtime/obsidian/service.mjs +66 -6
- package/src/runtime/obsidian/view-permission.mjs +334 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +19 -7
- package/templates/external-project-workspace/pack-lifecycle.json +2 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
const CONTROL_VARIANTS = new Set(['primary', 'secondary', 'quiet'])
|
|
2
|
+
const CONTROL_SIZES = new Set(['default', 'compact'])
|
|
3
|
+
const GROUP_LAYOUTS = new Set(['inline', 'grid'])
|
|
4
|
+
|
|
5
|
+
function enumValue(value, allowed, fallback) {
|
|
6
|
+
return allowed.has(value) ? value : fallback
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function htmlEscape(value) {
|
|
10
|
+
return String(value ?? '')
|
|
11
|
+
.replaceAll('&', '&')
|
|
12
|
+
.replaceAll('<', '<')
|
|
13
|
+
.replaceAll('>', '>')
|
|
14
|
+
.replaceAll('"', '"')
|
|
15
|
+
.replaceAll("'", ''')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function htmlAttr(name, value) {
|
|
19
|
+
if (value == null || value === false) return ''
|
|
20
|
+
if (value === true) return ` ${name}`
|
|
21
|
+
return ` ${name}="${htmlEscape(value)}"`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function htmlAttrs(attrs = {}) {
|
|
25
|
+
return Object.entries(attrs).map(([name, value]) => htmlAttr(name, value)).join('')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function controlAttrs(attrs = {}) {
|
|
29
|
+
return Object.fromEntries(Object.entries(attrs).filter(([name]) => (
|
|
30
|
+
/^(?:id|name|value|title|aria-[a-z0-9-]+|data-[a-z0-9-]+)$/.test(name)
|
|
31
|
+
)))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tenant-neutral control geometry and interaction behavior shared by Atelier
|
|
36
|
+
* tooling and compatible publication surfaces. Consumers theme the primitive
|
|
37
|
+
* exclusively through the --system-control-* custom properties below.
|
|
38
|
+
* Generated markup carries no upstream project or package identity.
|
|
39
|
+
*/
|
|
40
|
+
export const atelierControlStyles = `
|
|
41
|
+
.system-control {
|
|
42
|
+
display: inline-flex;
|
|
43
|
+
min-height: var(--system-control-height, 44px);
|
|
44
|
+
align-items: center;
|
|
45
|
+
justify-content: center;
|
|
46
|
+
gap: var(--system-control-gap, 0.5rem);
|
|
47
|
+
padding: 0 var(--system-control-pad-inline, 1.5rem);
|
|
48
|
+
border: 0;
|
|
49
|
+
border-radius: var(--system-control-radius, 0.875rem);
|
|
50
|
+
font: inherit;
|
|
51
|
+
font-size: var(--system-control-font-size, 1rem);
|
|
52
|
+
font-weight: var(--system-control-font-weight, 650);
|
|
53
|
+
line-height: 1.1;
|
|
54
|
+
letter-spacing: 0;
|
|
55
|
+
white-space: nowrap;
|
|
56
|
+
text-decoration: none;
|
|
57
|
+
cursor: pointer;
|
|
58
|
+
user-select: none;
|
|
59
|
+
transition:
|
|
60
|
+
background-color 0.2s var(--system-control-ease, ease),
|
|
61
|
+
color 0.2s var(--system-control-ease, ease),
|
|
62
|
+
box-shadow 0.2s var(--system-control-ease, ease),
|
|
63
|
+
transform 0.25s var(--system-control-ease, ease);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.system-control[data-variant="primary"] {
|
|
67
|
+
background: var(--system-control-primary-background, #c89d50);
|
|
68
|
+
color: var(--system-control-primary-foreground, #17110a);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.system-control[data-variant="secondary"] {
|
|
72
|
+
background: var(--system-control-secondary-background, transparent);
|
|
73
|
+
color: var(--system-control-secondary-foreground, #c89d50);
|
|
74
|
+
box-shadow: inset 0 0 0 1px var(--system-control-secondary-border, currentColor);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.system-control[data-variant="quiet"] {
|
|
78
|
+
background: var(--system-control-quiet-background, transparent);
|
|
79
|
+
color: var(--system-control-quiet-foreground, #c89d50);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.system-control[data-size="compact"] {
|
|
83
|
+
padding-inline: var(--system-control-compact-pad-inline, 1.125rem);
|
|
84
|
+
border-radius: var(--system-control-compact-radius, 0.6875rem);
|
|
85
|
+
font-size: var(--system-control-compact-font-size, 0.9375rem);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@media (hover: hover) and (pointer: fine) {
|
|
89
|
+
.system-control[data-variant="primary"]:hover {
|
|
90
|
+
background: var(--system-control-primary-hover-background, #b88d43);
|
|
91
|
+
color: var(--system-control-primary-hover-foreground, #17110a);
|
|
92
|
+
box-shadow: inset 0 0 0 1px var(--system-control-primary-hover-border, rgba(255, 255, 255, 0.25));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.system-control[data-variant="secondary"]:hover {
|
|
96
|
+
background: var(--system-control-secondary-hover-background, transparent);
|
|
97
|
+
color: var(--system-control-secondary-hover-foreground, #f4eee5);
|
|
98
|
+
box-shadow: inset 0 0 0 1px var(--system-control-secondary-hover-border, currentColor);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.system-control[data-variant="quiet"]:hover {
|
|
102
|
+
background: var(--system-control-quiet-hover-background, rgba(255, 255, 255, 0.02));
|
|
103
|
+
color: var(--system-control-quiet-hover-foreground, #f4eee5);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.system-control:active {
|
|
108
|
+
transform: translateY(1px);
|
|
109
|
+
transition-duration: 0.06s;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.system-control:focus-visible {
|
|
113
|
+
outline: none;
|
|
114
|
+
box-shadow: 0 0 0 3px var(--system-control-focus, rgba(200, 157, 80, 0.3));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.system-control:disabled,
|
|
118
|
+
.system-control[aria-disabled="true"] {
|
|
119
|
+
cursor: not-allowed;
|
|
120
|
+
opacity: 0.55;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.system-action-group {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-wrap: wrap;
|
|
126
|
+
gap: var(--system-action-group-gap, 0.75rem);
|
|
127
|
+
margin: 0;
|
|
128
|
+
padding: 0;
|
|
129
|
+
list-style: none;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.system-action-group[data-layout="grid"] {
|
|
133
|
+
display: grid;
|
|
134
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.system-action-group[data-layout="grid"] .system-control {
|
|
138
|
+
width: 100%;
|
|
139
|
+
justify-content: space-between;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@media (max-width: 40rem) {
|
|
143
|
+
.system-action-group[data-layout="grid"] {
|
|
144
|
+
grid-template-columns: 1fr;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@media (prefers-reduced-motion: reduce) {
|
|
149
|
+
.system-control,
|
|
150
|
+
.system-control:active {
|
|
151
|
+
transform: none;
|
|
152
|
+
transition: none;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
`.trim()
|
|
156
|
+
|
|
157
|
+
export function renderControl({
|
|
158
|
+
label,
|
|
159
|
+
href,
|
|
160
|
+
type = 'button',
|
|
161
|
+
variant = 'secondary',
|
|
162
|
+
size = 'default',
|
|
163
|
+
external = false,
|
|
164
|
+
arrow = false,
|
|
165
|
+
attrs = {},
|
|
166
|
+
} = {}) {
|
|
167
|
+
const resolvedVariant = enumValue(variant, CONTROL_VARIANTS, 'secondary')
|
|
168
|
+
const resolvedSize = enumValue(size, CONTROL_SIZES, 'default')
|
|
169
|
+
const content = `${htmlEscape(label)}${arrow ? '<span aria-hidden="true">→</span>' : ''}`
|
|
170
|
+
const shared = {
|
|
171
|
+
class: 'system-control',
|
|
172
|
+
'data-variant': resolvedVariant,
|
|
173
|
+
'data-size': resolvedSize,
|
|
174
|
+
...controlAttrs(attrs),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof href === 'string' && href.length > 0) {
|
|
178
|
+
return `<a${htmlAttr('href', href)}${htmlAttrs({
|
|
179
|
+
...shared,
|
|
180
|
+
...(external ? { target: '_blank', rel: 'noreferrer' } : {}),
|
|
181
|
+
})}>${content}</a>`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return `<button${htmlAttrs({ type, ...shared })}>${content}</button>`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function renderActionGroup(actions = [], {
|
|
188
|
+
label = 'Actions',
|
|
189
|
+
layout = 'inline',
|
|
190
|
+
variant = 'secondary',
|
|
191
|
+
size = 'default',
|
|
192
|
+
} = {}) {
|
|
193
|
+
const resolvedLayout = enumValue(layout, GROUP_LAYOUTS, 'inline')
|
|
194
|
+
const items = actions.map((action) => `<li>${renderControl({
|
|
195
|
+
...action,
|
|
196
|
+
variant: action.variant ?? variant,
|
|
197
|
+
size: action.size ?? size,
|
|
198
|
+
})}</li>`).join('')
|
|
199
|
+
|
|
200
|
+
return `<nav aria-label="${htmlEscape(label)}"><ul class="system-action-group" data-layout="${resolvedLayout}">${items}</ul></nav>`
|
|
201
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { atelierControlStyles } from './control-system.mjs'
|
|
2
|
+
|
|
1
3
|
export function htmlEscape(value) {
|
|
2
4
|
return String(value ?? '')
|
|
3
5
|
.replaceAll('&', '&')
|
|
@@ -35,6 +37,7 @@ export function renderPageShell({ title = 'MNSTRY Atelier', body = '' } = {}) {
|
|
|
35
37
|
'.meta{color:#666;font-size:13px}',
|
|
36
38
|
'pre{white-space:pre-wrap;background:#1f2424;color:#f5f5ee;border-radius:8px;padding:16px;overflow:auto}',
|
|
37
39
|
'.notice{border-left:4px solid #245f73;padding:12px 16px;background:#edf6f7}',
|
|
40
|
+
atelierControlStyles,
|
|
38
41
|
'</style>',
|
|
39
42
|
'</head>',
|
|
40
43
|
'<body>',
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Pure classifiers for the two consumer-install failures that PR #89 showed
|
|
2
|
+
// with no typed diagnostic. Nothing here runs npm or touches the network:
|
|
3
|
+
// consumer-smoke.mjs passes in npm's stderr and lockfiles and turns a finding
|
|
4
|
+
// into an AtelierDiagnosticError.
|
|
5
|
+
//
|
|
6
|
+
// - consumer-closure-incomplete: an offline install asked for a package the
|
|
7
|
+
// warmed cache does not hold (npm's ENOTCACHED).
|
|
8
|
+
// - override-not-inherited: a consumer's own lockfile holds a version, or a
|
|
9
|
+
// second copy, of a package the publisher pins with `overrides`. npm applies
|
|
10
|
+
// overrides only in the root project, so a consumer never receives them.
|
|
11
|
+
|
|
12
|
+
export const CONSUMER_CLOSURE_INCOMPLETE = 'consumer-closure-incomplete'
|
|
13
|
+
export const OVERRIDE_NOT_INHERITED = 'override-not-inherited'
|
|
14
|
+
|
|
15
|
+
const ENOTCACHED = /^npm (?:error|ERR!) code ENOTCACHED$/m
|
|
16
|
+
const REQUEST_URL = /^npm (?:error|ERR!) request to (\S+) failed/m
|
|
17
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
18
|
+
// An override key may select versions (`name@range`); only a bare name is an
|
|
19
|
+
// unconditional pin.
|
|
20
|
+
const SELECTOR_KEY = /^(?:@[^/@]+\/)?[^/@]+@/
|
|
21
|
+
// npm colours its output on a terminal and Windows hosts end lines with CRLF.
|
|
22
|
+
const ANSI = /\u001b\[[0-9;]*m/g
|
|
23
|
+
|
|
24
|
+
// The package and version npm asked for, from a registry URL. A tarball URL
|
|
25
|
+
// (`<registry>/<name>/-/<basename>-<version>.tgz`) names both; a packument URL
|
|
26
|
+
// (`<registry>/<name>`) names only the package.
|
|
27
|
+
function requestedPackage(url) {
|
|
28
|
+
let pathname
|
|
29
|
+
try { pathname = decodeURIComponent(new URL(url).pathname) } catch { return { package: null, version: null } }
|
|
30
|
+
const tarball = pathname.match(/^\/((?:@[^/]+\/)?[^/]+)\/-\/([^/]+)\.tgz$/)
|
|
31
|
+
if (tarball) {
|
|
32
|
+
const [, name, file] = tarball
|
|
33
|
+
const prefix = `${name.split('/').pop()}-`
|
|
34
|
+
return { package: name, version: file.startsWith(prefix) ? file.slice(prefix.length) : null }
|
|
35
|
+
}
|
|
36
|
+
const packument = pathname.match(/^\/((?:@[^/]+\/)?[^/]+)\/?$/)
|
|
37
|
+
return { package: packument ? packument[1] : null, version: null }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A finding for npm stderr that reports ENOTCACHED, else null. Any other npm
|
|
41
|
+
// failure is not classified here and keeps its own error.
|
|
42
|
+
export function classifyNpmFailure(stderr) {
|
|
43
|
+
const text = String(stderr ?? '').replace(ANSI, '').replace(/\r\n?/g, '\n')
|
|
44
|
+
if (!ENOTCACHED.test(text)) return null
|
|
45
|
+
const url = text.match(REQUEST_URL)?.[1] ?? null
|
|
46
|
+
return { code: CONSUMER_CLOSURE_INCOMPLETE, ...(url ? requestedPackage(url) : { package: null, version: null }), url }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isEntry = (entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry)
|
|
50
|
+
const underNodeModules = (path) => /(?:^|\/)node_modules\//.test(path)
|
|
51
|
+
|
|
52
|
+
// Every installed copy of `name` in a v2/v3 lockfile, one per node_modules
|
|
53
|
+
// path, sorted by path. A copy's name is the entry's own `name` (set for an
|
|
54
|
+
// npm: alias) or else the path after its last node_modules/. The root entry
|
|
55
|
+
// ('') is the consumer itself, and links are not copies. A path outside
|
|
56
|
+
// node_modules is a folder of the project (the target of a link): it is never
|
|
57
|
+
// a copy, and no name is derived from it. An entry that is not an object is
|
|
58
|
+
// not a copy either.
|
|
59
|
+
function installedCopies(lockfile, name) {
|
|
60
|
+
return Object.entries(lockfile?.packages ?? {})
|
|
61
|
+
.filter(([path, entry]) => path !== '' && isEntry(entry) && !entry.link && underNodeModules(path)
|
|
62
|
+
&& (entry.name ?? path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length)) === name)
|
|
63
|
+
.map(([path, entry]) => ({ path, version: entry.version ?? null }))
|
|
64
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One finding per publisher override the consumer's lockfile does not honour:
|
|
68
|
+
// a copy at another version, or more than one copy. An override whose package
|
|
69
|
+
// the consumer does not install has nothing to honour; `compared` names the
|
|
70
|
+
// overrides that had at least one copy to check. Only unconditional
|
|
71
|
+
// exact-version overrides can be compared, so a version-selector key or a
|
|
72
|
+
// non-exact value is refused rather than skipped.
|
|
73
|
+
export function overrideFindings({ publisherOverrides, consumerLock }) {
|
|
74
|
+
if (!consumerLock || typeof consumerLock.packages !== 'object' || consumerLock.packages === null) {
|
|
75
|
+
throw new TypeError('consumer lockfile must be lockfileVersion 2 or 3 with a packages map')
|
|
76
|
+
}
|
|
77
|
+
const findings = []
|
|
78
|
+
const compared = []
|
|
79
|
+
for (const [name, pinned] of Object.entries(publisherOverrides ?? {})) {
|
|
80
|
+
if (SELECTOR_KEY.test(name)) {
|
|
81
|
+
throw new TypeError(`override key ${name} selects versions; the captured-closure check compares unconditional pins only`)
|
|
82
|
+
}
|
|
83
|
+
if (typeof pinned !== 'string' || !EXACT_VERSION.test(pinned)) {
|
|
84
|
+
throw new TypeError(`override for ${name} is not an exact version; the captured-closure check compares exact pins only`)
|
|
85
|
+
}
|
|
86
|
+
const found = installedCopies(consumerLock, name)
|
|
87
|
+
if (found.length > 0) compared.push(name)
|
|
88
|
+
if (found.length > 1 || found.some((copy) => copy.version !== pinned)) {
|
|
89
|
+
findings.push({ code: OVERRIDE_NOT_INHERITED, package: name, pinned, found })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { findings, compared }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The registry tarballs an offline `npm ci` needs from the consumer's own
|
|
96
|
+
// lockfile: every entry with an http(s) `resolved` URL and an `integrity`.
|
|
97
|
+
// Local entries (the candidate's `file:` tarball, links) and bundled entries
|
|
98
|
+
// are not cached.
|
|
99
|
+
// `missing` lists registry-installed paths that lack either field, which an
|
|
100
|
+
// offline reinstall could not satisfy. An entry that is not an object is
|
|
101
|
+
// listed as missing too: it names nothing that could be reinstalled.
|
|
102
|
+
export function capturedClosure(consumerLock) {
|
|
103
|
+
const tarballs = []
|
|
104
|
+
const missing = []
|
|
105
|
+
for (const [path, entry] of Object.entries(consumerLock?.packages ?? {})) {
|
|
106
|
+
if (path === '') continue
|
|
107
|
+
if (!isEntry(entry)) {
|
|
108
|
+
missing.push(path)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (entry.link || entry.inBundle) continue
|
|
112
|
+
const resolved = typeof entry.resolved === 'string' ? entry.resolved : null
|
|
113
|
+
if (resolved && !/^https?:\/\//.test(resolved)) continue
|
|
114
|
+
if (resolved && typeof entry.integrity === 'string') tarballs.push({ path, resolved, integrity: entry.integrity })
|
|
115
|
+
else missing.push(path)
|
|
116
|
+
}
|
|
117
|
+
const byPath = (left, right) => (typeof left === 'string' ? left.localeCompare(right) : left.path.localeCompare(right.path))
|
|
118
|
+
return { tarballs: tarballs.sort(byPath), missing: missing.sort(byPath) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The installed tree from `npm ls --all --json`, one sorted line per package
|
|
122
|
+
// with its ancestry, so two installs of the same project can be compared and
|
|
123
|
+
// the first difference named.
|
|
124
|
+
export function npmTreeLines(npmLsJson) {
|
|
125
|
+
const lines = []
|
|
126
|
+
const visit = (dependencies, ancestry) => {
|
|
127
|
+
for (const [name, entry] of Object.entries(dependencies ?? {})) {
|
|
128
|
+
const line = `${ancestry}${name}@${entry?.version ?? 'missing'}`
|
|
129
|
+
lines.push(line)
|
|
130
|
+
visit(entry?.dependencies, `${line} > `)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
visit(npmLsJson?.dependencies, '')
|
|
134
|
+
return lines.sort()
|
|
135
|
+
}
|