@vltpkg/graph 1.4.0 → 1.6.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 (53) hide show
  1. package/README.md +62 -3
  2. package/dist/actual/load.js +24 -12
  3. package/dist/build.js +30 -5
  4. package/dist/dependencies.d.ts +11 -0
  5. package/dist/dependencies.js +47 -7
  6. package/dist/filter-nodes-by-query.d.ts +39 -0
  7. package/dist/filter-nodes-by-query.js +100 -0
  8. package/dist/graph.js +3 -0
  9. package/dist/ideal/append-nodes.js +29 -11
  10. package/dist/ideal/get-importer-specs.js +17 -7
  11. package/dist/ideal/remove-satisfied-specs.js +18 -10
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.js +2 -0
  14. package/dist/install.d.ts +25 -8
  15. package/dist/install.js +25 -1
  16. package/dist/lockfile/load-edges.js +10 -2
  17. package/dist/modifiers.d.ts +11 -0
  18. package/dist/modifiers.js +105 -21
  19. package/dist/reify/add-edge.d.ts +4 -0
  20. package/dist/reify/add-edge.js +13 -7
  21. package/dist/reify/build.d.ts +7 -0
  22. package/dist/reify/check-needed-build.d.ts +5 -0
  23. package/dist/reify/check-needed-build.js +13 -0
  24. package/dist/reify/delete-edge.js +16 -14
  25. package/dist/reify/delete-nodes.d.ts +2 -1
  26. package/dist/reify/delete-nodes.js +4 -2
  27. package/dist/reify/index.d.ts +2 -0
  28. package/dist/reify/index.js +11 -39
  29. package/dist/reify/relink-bins.d.ts +9 -0
  30. package/dist/reify/relink-bins.js +39 -0
  31. package/dist/reify/update-importers-package-json.d.ts +6 -1
  32. package/dist/reify/update-importers-package-json.js +16 -15
  33. package/dist/skills/dirs.d.ts +58 -0
  34. package/dist/skills/dirs.js +211 -0
  35. package/dist/skills/discover.d.ts +73 -0
  36. package/dist/skills/discover.js +238 -0
  37. package/dist/skills/gitignore.d.ts +18 -0
  38. package/dist/skills/gitignore.js +82 -0
  39. package/dist/skills/index.d.ts +124 -0
  40. package/dist/skills/index.js +254 -0
  41. package/dist/skills/lazy.d.ts +8 -0
  42. package/dist/skills/lazy.js +8 -0
  43. package/dist/skills/mask.d.ts +26 -0
  44. package/dist/skills/mask.js +43 -0
  45. package/dist/skills/mounts.d.ts +78 -0
  46. package/dist/skills/mounts.js +162 -0
  47. package/dist/uninstall.d.ts +27 -7
  48. package/dist/uninstall.js +25 -1
  49. package/dist/update.d.ts +24 -4
  50. package/dist/update.js +24 -1
  51. package/dist/visualization/mermaid-output.d.ts +8 -0
  52. package/dist/visualization/mermaid-output.js +25 -8
  53. package/package.json +21 -21
package/README.md CHANGED
@@ -78,15 +78,74 @@ minimal filesystem changes (creating/deleting links, writing
78
78
  lockfiles, hoisting, lifecycle scripts) to make the on-disk install
79
79
  match the Ideal graph. Returns `{ diff, buildQueue }`.
80
80
 
81
- ### `install(options, add?): Promise<{ graph, diff, buildQueue }>`
81
+ ### `install(options, add?): Promise<{ graph, diff, buildQueue, skills? }>`
82
82
 
83
83
  High-level install orchestration that handles graph building, reify,
84
84
  and lockfile management. Supports `--frozen-lockfile`,
85
- `--clean-install`, and `--lockfile-only` modes.
85
+ `--clean-install`, and `--lockfile-only` modes. With `allowSkills` (a
86
+ DSS query whose selectors may end with `:skill()`, validated up
87
+ front), links the agent skills of matching packages afterwards (see
88
+ `linkSkills`) into `skillsDirs` (unset: detected) and returns the
89
+ result as `skills`. Unset or `:not(*)`: no skills work, the skills
90
+ code is not loaded. `update` and `uninstall` take `allowSkills` too.
91
+
92
+ ### `listSkills(options): Promise<{ project, graph, skills, dirs, skipped? }>`
93
+
94
+ Lists the agent skills (dirs with a `SKILL.md`, see
95
+ https://agentskills.io/specification) shipped by installed packages in
96
+ the vlt store and the project's workspaces (a root `SKILL.md`, then
97
+ `<dir>/SKILL.md` in `skills/`, `dist/skills/` and `.agents/skills/`;
98
+ the first skill of a name wins), from one version per package (a
99
+ direct dep or workspace, else the highest), with the agent skill dirs
100
+ each is linked in, `project`: the main importer name, and the `graph`.
101
+ `options.target` is a DSS query selecting packages (default `*`); a
102
+ selector ending with `:skill(<name>, …)` keeps only those skills of
103
+ its packages. Creates nothing.
104
+
105
+ ### `linkSkills(options): Promise<LinkSkillsResult>`
106
+
107
+ Links the skills picked by `options.target` (as in `listSkills`; comma
108
+ lists are a union) into each agent skill dir (junctions on Windows):
109
+ `options.skillsDirs`, else `.agents/skills`, plus `<folder>/skills`
110
+ for each agent folder at the project root, `.claude/skills` inside
111
+ Claude Code (`options.env`), and existing `.<name>/skills` dirs. Dirs
112
+ must resolve inside the project, not in `node_modules` (else
113
+ `skipped`, as are fs errors) and are created only when a skill is
114
+ picked. One package per name, among picked skills, in every dir: the
115
+ one the first existing link points to while its skill is installed,
116
+ else a direct dep or workspace, then DepID; the others are reported in
117
+ `conflicts` as `<mount> (<package>)`. Installs keep a dir's link to
118
+ another installed skill. A link follows its package to the picked
119
+ version. Then removes links whose skill is no longer installed:
120
+ dangling, package not in the graph, or no longer shipped. With
121
+ `options.apply`, also removes every other vlt link in the scanned
122
+ dirs. Only vlt links (into a `node_modules/.vlt` store, or listed in
123
+ the dir's `.gitignore` block and pointing to a workspace or dangling)
124
+ are ever replaced or removed; anything else in the way is reported in
125
+ `conflicts`. Keeps a marked block of link names in each dir's
126
+ `.gitignore` (`gitignore` lists the ones it could not edit). Linked
127
+ packages get private copies of files hardlinked from the global store.
128
+ `:skill()` patterns that match no skill of their packages are returned
129
+ in `unmatched`. Security queries fail closed: skills of npm registry
130
+ packages with no security data are left out (in `listSkills` too) and
131
+ returned in `unscanned` with `unscannedReason` (by `linkSkills`:
132
+ unless still linked).
133
+
134
+ ### `assertQuery(selector, flag, partSelectors?): Promise<void>`
135
+
136
+ Throws `EUSAGE` unless `selector` is a valid DSS query.
137
+ `partSelectors` (e.g. `['skill']`) allows `:skill()`.
138
+
139
+ ### `hasSkills(node): Promise<boolean>`
140
+
141
+ `:skills` provider for `Query`: true when an installed package ships
142
+ agent skills, as `listSkills` finds them.
86
143
 
87
144
  ### `mermaidOutput(graph): string`
88
145
 
89
- Generates Mermaid flowchart syntax from graph data.
146
+ Generates Mermaid flowchart syntax from graph data. `graph.children`
147
+ (`Map<DepID, { label, edge? }[]>`) draws leaves under nodes, e.g.
148
+ `vlt skills list` skills.
90
149
 
91
150
  ### `humanReadableOutput(graph, options): string`
92
151
 
@@ -2,7 +2,7 @@ import { asDepID, hydrate, joinDepIDTuple, joinExtra, splitDepID, splitExtra, }
2
2
  import { Spec } from '@vltpkg/spec';
3
3
  import { graphStep } from '@vltpkg/output';
4
4
  import { isObject } from '@vltpkg/types';
5
- import { shorten, getRawDependencies, getDependencies, } from "../dependencies.js";
5
+ import { shorten, getRawDependencies, getDependencies, asDependencySpecError, } from "../dependencies.js";
6
6
  import { Graph } from "../graph.js";
7
7
  import { loadHidden } from "../lockfile/load.js";
8
8
  import { saveHidden } from "../lockfile/save.js";
@@ -178,13 +178,19 @@ const parseDir = (options, scurry, packageJson, depsFound, graph, fromNode, curr
178
178
  const type = deps?.type || 'dependencies';
179
179
  const bareSpec = deps?.bareSpec || '*';
180
180
  const depType = shorten(type, alias, fromNode.manifest);
181
- let spec = Spec.parse(alias, bareSpec, {
182
- ...options,
183
- // fall back to options.registry (which the lockfile merges into)
184
- // so importer-level edges don't silently revert to the default
185
- // npm registry. see vltpkg/vltpkg#1580.
186
- registry: fromNode.registry ?? options.registry,
187
- });
181
+ let spec;
182
+ try {
183
+ spec = Spec.parse(alias, bareSpec, {
184
+ ...options,
185
+ // fall back to options.registry (which the lockfile merges into)
186
+ // so importer-level edges don't silently revert to the default
187
+ // npm registry. see vltpkg/vltpkg#1580.
188
+ registry: fromNode.registry ?? options.registry,
189
+ });
190
+ }
191
+ catch (er) {
192
+ throw asDependencySpecError(er, fromNode, depType);
193
+ }
188
194
  // Check for active modifiers and replace spec if a modifier is complete
189
195
  const { spec: modifiedSpec, queryModifier } = maybeApplyModifierToSpec(spec, alias, modifierRefs);
190
196
  spec = modifiedSpec;
@@ -240,10 +246,16 @@ const parseDir = (options, scurry, packageJson, depsFound, graph, fromNode, curr
240
246
  for (const { name, type, bareSpec } of dependencies.values()) {
241
247
  if (!seenDeps.has(name)) {
242
248
  const depType = shorten(type, name, fromNode.manifest);
243
- let spec = Spec.parse(name, bareSpec, {
244
- ...options,
245
- registry: fromNode.registry ?? options.registry,
246
- });
249
+ let spec;
250
+ try {
251
+ spec = Spec.parse(name, bareSpec, {
252
+ ...options,
253
+ registry: fromNode.registry ?? options.registry,
254
+ });
255
+ }
256
+ catch (er) {
257
+ throw asDependencySpecError(er, fromNode, depType);
258
+ }
247
259
  // Check for active modifiers and replace spec for missing dependencies
248
260
  const { spec: modifiedSpec, queryModifier } = maybeApplyModifierToSpec(spec, name, modifierRefs);
249
261
  // a `-` modifier removes the dependency, so it is not missing
package/dist/build.js CHANGED
@@ -4,10 +4,12 @@ import { build as reifyBuild } from "./reify/build.js";
4
4
  import { Diff } from "./diff.js";
5
5
  import { Graph } from "./graph.js";
6
6
  import { Query } from '@vltpkg/query';
7
- import { SecurityArchive } from '@vltpkg/security-archive';
7
+ import { SecurityArchive, usesNpmRegistry, } from '@vltpkg/security-archive';
8
8
  import { saveHidden } from "./lockfile/save.js";
9
+ import { hasSkills } from "./skills/lazy.js";
9
10
  /**
10
- * Filter nodes using a DSS query string
11
+ * Filter nodes using a DSS query string. `skipped` holds matched nodes
12
+ * left out because they have no security data, `skippedReason` says why.
11
13
  */
12
14
  const filterNodesByQuery = async (targetQuery, graph) => {
13
15
  /* c8 ignore start */
@@ -25,11 +27,30 @@ const filterNodesByQuery = async (targetQuery, graph) => {
25
27
  nodes,
26
28
  importers,
27
29
  securityArchive,
30
+ hasSkills,
28
31
  });
29
32
  const { nodes: resultNodes } = await query.search(targetQuery, {
30
33
  signal: new AbortController().signal,
31
34
  });
32
- return new Set(resultNodes.map(node => node.id));
35
+ // fail closed: never run scripts of pkgs with no security data, be it
36
+ // from a timed out request or not scanned by socket.dev yet
37
+ const allowed = new Set();
38
+ const skipped = new Set();
39
+ for (const node of resultNodes) {
40
+ if (securityArchive &&
41
+ !securityArchive.has(node.id) &&
42
+ usesNpmRegistry(node)) {
43
+ skipped.add(node.id);
44
+ }
45
+ else {
46
+ allowed.add(node.id);
47
+ }
48
+ }
49
+ return {
50
+ allowed,
51
+ skipped,
52
+ skippedReason: securityArchive?.timedOut ? 'timeout' : 'unscanned',
53
+ };
33
54
  };
34
55
  /**
35
56
  * Build the project based on actual graph state and build state from lockfile
@@ -54,7 +75,7 @@ export const build = async (options) => {
54
75
  loadManifests: true,
55
76
  });
56
77
  // Filter nodes using target query provided
57
- const targetFilteredNodes = await filterNodesByQuery(target, actualGraph);
78
+ const { allowed: targetFilteredNodes, skipped, skippedReason, } = await filterNodesByQuery(target, actualGraph);
58
79
  // Create a total diff including the actual graph as 'to'
59
80
  const diff = new Diff(new Graph({
60
81
  ...options,
@@ -83,5 +104,9 @@ export const build = async (options) => {
83
104
  graph: actualGraph,
84
105
  });
85
106
  }
86
- return buildResult;
107
+ // needed builds left out for lack of security data
108
+ const skippedNodes = [...diff.nodes.add].filter(node => skipped.has(node.id));
109
+ return skippedNodes.length ?
110
+ { ...buildResult, skipped: skippedNodes, skippedReason }
111
+ : buildResult;
87
112
  };
@@ -74,6 +74,17 @@ export declare const shorten: (typeLong: DependencyTypeLong, name?: string, mani
74
74
  */
75
75
  export declare const shouldInstallDepType: (node: NodeLike, depType: DependencyTypeLong) => boolean;
76
76
  export declare const getRawDependencies: (node: NodeLike) => Map<string, RawDependency>;
77
+ /**
78
+ * Maps short dependency types to the `package.json` field declaring them.
79
+ */
80
+ export declare const longDependencyTypeNames: Record<DependencyTypeShort, DependencyTypeLong>;
81
+ /**
82
+ * Point an `ECONFIG` spec error (eg: an undefined `catalog:`) at the
83
+ * package.json & dependency type that declared it. `source` names the
84
+ * file the spec was read from when not the manifest (eg: a lockfile).
85
+ * Other values are returned as is.
86
+ */
87
+ export declare const asDependencySpecError: (er: unknown, from: NodeLike, type: DependencyTypeShort, source?: string) => unknown;
77
88
  /**
78
89
  * Retrieves a map of all dependencies, of all types, that can be inferred
79
90
  * from a given node manifest, including missing dependencies.
@@ -1,6 +1,6 @@
1
1
  import { error } from '@vltpkg/error-cause';
2
2
  import { Spec } from '@vltpkg/spec/browser';
3
- import { dependencyTypes, longDependencyTypes, shortDependencyTypes, } from '@vltpkg/types';
3
+ import { isErrorWithCause, isObject, dependencyTypes, longDependencyTypes, shortDependencyTypes, } from '@vltpkg/types';
4
4
  export const isDependencyTypeShort = (obj) => shortDependencyTypes.has(obj);
5
5
  export const isDependencySaveType = (obj) => shortDependencyTypes.has(obj) ||
6
6
  obj === 'implicit';
@@ -113,6 +113,40 @@ export const getRawDependencies = (node) => {
113
113
  }
114
114
  return dependencies;
115
115
  };
116
+ /**
117
+ * Maps short dependency types to the `package.json` field declaring them.
118
+ */
119
+ export const longDependencyTypeNames = {
120
+ prod: 'dependencies',
121
+ dev: 'devDependencies',
122
+ optional: 'optionalDependencies',
123
+ peer: 'peerDependencies',
124
+ peerOptional: 'peerDependencies',
125
+ };
126
+ // `./`-relative unless already relative or absolute
127
+ const manifestPath = ({ location = '.' }) => `${/^(?:\.\.?(?:\/|$)|\/|[a-z]:)/i.test(location) ? '' : './'}${location}/package.json`;
128
+ /**
129
+ * Point an `ECONFIG` spec error (eg: an undefined `catalog:`) at the
130
+ * package.json & dependency type that declared it. `source` names the
131
+ * file the spec was read from when not the manifest (eg: a lockfile).
132
+ * Other values are returned as is.
133
+ */
134
+ export const asDependencySpecError = (er, from, type, source) => {
135
+ if (!isErrorWithCause(er) ||
136
+ !isObject(er.cause) ||
137
+ er.cause.code !== 'ECONFIG') {
138
+ return er;
139
+ }
140
+ const pj = manifestPath(from);
141
+ const depType = longDependencyTypeNames[type];
142
+ return error(er.message, {
143
+ ...er.cause,
144
+ from: source ?
145
+ `${source} (${pj} ${depType})`
146
+ : `${pj} (${depType})`,
147
+ cause: er,
148
+ }, asDependencySpecError);
149
+ };
116
150
  /**
117
151
  * Retrieves a map of all dependencies, of all types, that can be inferred
118
152
  * from a given node manifest, including missing dependencies.
@@ -122,12 +156,18 @@ export const getDependencies = (node, options) => {
122
156
  const dependencies = getRawDependencies(node);
123
157
  for (const { name, type, bareSpec } of dependencies.values()) {
124
158
  const depType = shorten(type, name, node.manifest);
125
- const spec = Spec.parse(name, bareSpec, {
126
- ...options,
127
- // fall back to options.registry so deps don't lose the configured
128
- // registry when the node itself has none. see vltpkg/vltpkg#1580.
129
- registry: node.registry ?? options.registry,
130
- });
159
+ let spec;
160
+ try {
161
+ spec = Spec.parse(name, bareSpec, {
162
+ ...options,
163
+ // fall back to options.registry so deps don't lose the configured
164
+ // registry when the node itself has none. see vltpkg/vltpkg#1580.
165
+ registry: node.registry ?? options.registry,
166
+ });
167
+ }
168
+ catch (er) {
169
+ throw asDependencySpecError(er, node, depType);
170
+ }
131
171
  res.set(name, {
132
172
  spec,
133
173
  type: depType,
@@ -0,0 +1,39 @@
1
+ import type { QueryOptions } from '@vltpkg/query';
2
+ import type { DepID } from '@vltpkg/dep-id';
3
+ import type { Graph } from './graph.ts';
4
+ /**
5
+ * One top-level selector of a query: the ids it matched, and its
6
+ * `:skill()` patterns (unset: every skill).
7
+ */
8
+ export type QueryPartIds = {
9
+ ids: Set<DepID>;
10
+ patterns?: string[];
11
+ };
12
+ /**
13
+ * Matched npm registry pkgs with no security data, and why: the
14
+ * request timed out, or socket.dev has not scanned them yet.
15
+ */
16
+ export type Unscanned = {
17
+ ids: Set<DepID>;
18
+ reason: 'timeout' | 'unscanned';
19
+ };
20
+ /**
21
+ * Filter nodes using a DSS query string
22
+ */
23
+ export declare const filterNodesByQuery: (graph: Graph, selector?: string) => Promise<Set<DepID>>;
24
+ /**
25
+ * {@link filterNodesByQuery} for `allow-skills`: accepts `:skill()`,
26
+ * and also returns one entry per top-level selector. Unlike it, keeps
27
+ * `unscanned` pkgs: callers must drop them (fail closed).
28
+ */
29
+ export declare const filterNodesByQueryParts: (graph: Graph, selector?: string) => Promise<{
30
+ ids: Set<DepID>;
31
+ parts: QueryPartIds[];
32
+ unscanned?: Unscanned;
33
+ }>;
34
+ /**
35
+ * Throw `EUSAGE` unless `selector` is a valid DSS query. Runs it on an
36
+ * empty graph: no fs, network or security data needed. `partSelectors`
37
+ * allows e.g. `:skill()`.
38
+ */
39
+ export declare const assertQuery: (selector: string, flag: string, partSelectors?: QueryOptions["partSelectors"]) => Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { error } from '@vltpkg/error-cause';
2
+ import { Query } from '@vltpkg/query';
3
+ import { SecurityArchive, usesNpmRegistry, } from '@vltpkg/security-archive';
4
+ import { asError } from '@vltpkg/types';
5
+ import { hasSkills } from "./skills/lazy.js";
6
+ const toIds = (nodes) => new Set(nodes.map(n => n.id));
7
+ const search = async (graph, selector, partSelectors) => {
8
+ /* c8 ignore start */
9
+ const securityArchive = Query.hasSecuritySelectors(selector) ?
10
+ await SecurityArchive.start({
11
+ nodes: [...graph.nodes.values()],
12
+ })
13
+ : undefined;
14
+ /* c8 ignore stop */
15
+ const res = await new Query({
16
+ edges: graph.edges,
17
+ nodes: new Set(graph.nodes.values()),
18
+ importers: graph.importers,
19
+ securityArchive,
20
+ hasSkills,
21
+ partSelectors,
22
+ }).search(selector, { signal: new AbortController().signal });
23
+ if (!securityArchive)
24
+ return res;
25
+ // fail closed: callers skip pkgs with no security data, be it from a
26
+ // timed out request or not scanned by socket.dev yet
27
+ const ids = toIds(res.nodes.filter(n => !securityArchive.has(n.id) && usesNpmRegistry(n)));
28
+ if (!ids.size)
29
+ return res;
30
+ const reason = securityArchive.timedOut ? 'timeout' : 'unscanned';
31
+ return { ...res, unscanned: { ids, reason } };
32
+ };
33
+ /**
34
+ * Filter nodes using a DSS query string
35
+ */
36
+ export const filterNodesByQuery = async (graph, selector) => {
37
+ // shortcut no packages included
38
+ if (selector === ':not(*)' /* c8 ignore next */ || !selector) {
39
+ return new Set();
40
+ }
41
+ // shortcut all packages included
42
+ if (selector === '*') {
43
+ return new Set(graph.nodes.keys());
44
+ }
45
+ const { nodes, unscanned } = await search(graph, selector);
46
+ return toIds(nodes.filter(n => !unscanned?.ids.has(n.id)));
47
+ };
48
+ /**
49
+ * {@link filterNodesByQuery} for `allow-skills`: accepts `:skill()`,
50
+ * and also returns one entry per top-level selector. Unlike it, keeps
51
+ * `unscanned` pkgs: callers must drop them (fail closed).
52
+ */
53
+ export const filterNodesByQueryParts = async (graph, selector) => {
54
+ if (!selector || selector === ':not(*)') {
55
+ return { ids: new Set(), parts: [] };
56
+ }
57
+ if (selector === '*') {
58
+ const ids = new Set(graph.nodes.keys());
59
+ return { ids, parts: [{ ids }] };
60
+ }
61
+ const res = await search(graph, selector, ['skill']);
62
+ return {
63
+ ids: toIds(res.nodes),
64
+ /* c8 ignore next -- always set when opted in */
65
+ parts: (res.parts ?? []).map(p => ({
66
+ ids: toIds(p.nodes),
67
+ ...(p.patterns && { patterns: p.patterns }),
68
+ })),
69
+ ...(res.unscanned && { unscanned: res.unscanned }),
70
+ };
71
+ };
72
+ /**
73
+ * Throw `EUSAGE` unless `selector` is a valid DSS query. Runs it on an
74
+ * empty graph: no fs, network or security data needed. `partSelectors`
75
+ * allows e.g. `:skill()`.
76
+ */
77
+ export const assertQuery = async (selector, flag, partSelectors) => {
78
+ try {
79
+ await new Query({
80
+ edges: new Set(),
81
+ nodes: new Set(),
82
+ importers: new Set(),
83
+ securityArchive: new Map(),
84
+ hasSkills,
85
+ partSelectors,
86
+ }).search(selector, { signal: new AbortController().signal });
87
+ }
88
+ catch (cause) {
89
+ const { message, cause: c } = asError(cause);
90
+ // a typo's suggestions, e.g. `:skill` for `:skills`
91
+ const validOptions = c?.validOptions ??
92
+ (c?.wanted === undefined ? undefined : [c.wanted]);
93
+ throw error(`Invalid --${flag} query: ${message}`, {
94
+ code: 'EUSAGE',
95
+ found: selector,
96
+ cause,
97
+ ...(validOptions && { validOptions }),
98
+ }, assertQuery);
99
+ }
100
+ };
package/dist/graph.js CHANGED
@@ -347,6 +347,9 @@ export class Graph {
347
347
  }
348
348
  return edge;
349
349
  }
350
+ // a stale edgesIn ref would let removing the old target drop
351
+ // the new edge from `from.edgesOut`
352
+ edge.to?.edgesIn.delete(edge);
350
353
  this.edges.delete(edge);
351
354
  }
352
355
  this.mutations++;
@@ -5,7 +5,7 @@ import { satisfies } from '@vltpkg/satisfies';
5
5
  import { parse as parseVersion } from '@vltpkg/semver';
6
6
  import { longDependencyTypes, normalizeManifest } from '@vltpkg/types';
7
7
  import { fixupAddedNames } from "../fixup-added-names.js";
8
- import { addKey, shorten, shouldInstallDepType, } from "../dependencies.js";
8
+ import { addKey, asDependencySpecError, shorten, shouldInstallDepType, } from "../dependencies.js";
9
9
  import { removeOptionalSubgraph } from "../remove-optional-subgraph.js";
10
10
  import { isPathSecurityError } from "../path-security-error.js";
11
11
  import { extractNode } from "../reify/extract-node.js";
@@ -419,7 +419,7 @@ const fetchManifestsForDeps = async (packageInfo, graph, fromNode, deps, scurry,
419
419
  * vlt store are extracted immediately (in parallel) instead of waiting for
420
420
  * the full ideal graph to be built. This significantly improves install time.
421
421
  */
422
- const processPlacementTasks = async (graph, options, placementTasks, add, modifiers, scurry, packageInfo, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove) => {
422
+ const processPlacementTasks = async (graph, options, placementTasks, add, modifiers, scurry, packageInfo, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove, explicit) => {
423
423
  const childDepsToProcess = [];
424
424
  // Note: placementTasks are already sorted by fetchManifestsForDeps
425
425
  // using compareByHasPeerDeps to ensure non-peer deps are processed first.
@@ -431,18 +431,31 @@ const processPlacementTasks = async (graph, options, placementTasks, add, modifi
431
431
  // reify step to properly update the package.json file dependencies
432
432
  // using the correct names retrieved from the manifest data
433
433
  const additiveMap = fromNode.importer ? add : transientAdd?.get(fromNode.id);
434
+ // a nameless explicit add is known by its fixed up name from here on
435
+ const explicitKeys = explicit?.get(fromNode.id);
436
+ const wasExplicit = explicitKeys?.has(addKey(spec));
434
437
  spec = fixupAddedNames(additiveMap, manifest, options, spec);
438
+ if (wasExplicit)
439
+ explicitKeys?.add(spec.name);
435
440
  // handles missing manifest resolution
436
441
  if (!manifest) {
437
442
  if (!edgeOptional && fromNode.isOptional()) {
438
443
  // failed resolution of a non-optional dep of an optional node
439
444
  // have to clean up the dependents
440
- removeOptionalSubgraph(graph, fromNode);
445
+ for (const n of removeOptionalSubgraph(graph, fromNode)) {
446
+ // pruned importer deps: nothing to save, unless requested
447
+ for (const { from, name } of n.edgesIn) {
448
+ if (from.importer && !explicit?.get(from.id)?.has(name)) {
449
+ add?.delete(name);
450
+ }
451
+ }
452
+ }
441
453
  continue;
442
454
  }
443
455
  else if (edgeOptional) {
444
- // failed resolution of an optional dep, just ignore it,
445
- // nothing to prune because we never added it in the first place.
456
+ // failed resolution of an optional dep: skip it and drop it
457
+ // from the add map, nothing to prune or save
458
+ additiveMap?.delete(addKey(spec));
446
459
  continue;
447
460
  }
448
461
  else {
@@ -549,15 +562,20 @@ const processPlacementTasks = async (graph, options, placementTasks, add, modifi
549
562
  // might need to skip already placed peer deps here
550
563
  if (bundled.has(name))
551
564
  continue;
552
- const dep = {
553
- type: shorten(depTypeName, name, manifest),
554
- spec: Spec.parse(name, bareSpec, {
565
+ const type = shorten(depTypeName, name, manifest);
566
+ let depSpec;
567
+ try {
568
+ depSpec = Spec.parse(name, bareSpec, {
555
569
  ...options,
556
570
  // git/file/remote parents have no spec.registry, so fall
557
571
  // back to the configured one. see vltpkg/vltpkg#1580.
558
572
  registry: spec.registry ?? options.registry,
559
- }),
560
- };
573
+ });
574
+ }
575
+ catch (er) {
576
+ throw asDependencySpecError(er, node, type);
577
+ }
578
+ const dep = { type, spec: depSpec };
561
579
  if (depTypeName === 'peerDependencies') {
562
580
  nextPeerDeps.set(name, dep);
563
581
  }
@@ -733,7 +751,7 @@ export const appendNodes = async (packageInfo, graph, fromNode, deps, scurry, op
733
751
  graph.addEdge(type, spec, fromNode, toNode);
734
752
  }
735
753
  // Place nodes and collect child deps
736
- const placed = await processPlacementTasks(graph, options, result.placementTasks, add, modifiers, scurry, packageInfo, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove);
754
+ const placed = await processPlacementTasks(graph, options, result.placementTasks, add, modifiers, scurry, packageInfo, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove, explicit);
737
755
  levelResults.push(placed);
738
756
  }
739
757
  // ============================================================
@@ -1,5 +1,5 @@
1
1
  import { longDependencyTypes } from '@vltpkg/types';
2
- import { shorten, asDependency } from "../dependencies.js";
2
+ import { shorten, asDependency, asDependencySpecError, } from "../dependencies.js";
3
3
  import { removeSatisfiedSpecs } from "./remove-satisfied-specs.js";
4
4
  import { Spec } from '@vltpkg/spec';
5
5
  const hasDepName = (importer, edge) => {
@@ -72,7 +72,13 @@ export const getImporterSpecs = (options) => {
72
72
  edge.type === type) {
73
73
  continue;
74
74
  }
75
- const spec = Spec.parse(depName, depSpec, options);
75
+ let spec;
76
+ try {
77
+ spec = Spec.parse(depName, depSpec, options);
78
+ }
79
+ catch (er) {
80
+ throw asDependencySpecError(er, importer, type);
81
+ }
76
82
  // a `-` modifier removes this dependency, nothing to place; an
77
83
  // edge from before (e.g. a qualifier now matching) has to go
78
84
  if (options.modifiers?.removesImporterEdge(importer, spec)) {
@@ -137,11 +143,15 @@ export const getImporterSpecs = (options) => {
137
143
  if (edge?.to && edge.spec.bareSpec === depSpec)
138
144
  continue;
139
145
  // add the dependency to the addDeps map
140
- const dependency = asDependency({
141
- spec: Spec.parse(depName, depSpec, options),
142
- type: shorten(depType, depName, manifest),
143
- });
144
- addDeps.set(depName, dependency);
146
+ const type = shorten(depType, depName, manifest);
147
+ let spec;
148
+ try {
149
+ spec = Spec.parse(depName, depSpec, options);
150
+ }
151
+ catch (er) {
152
+ throw asDependencySpecError(er, node, type);
153
+ }
154
+ addDeps.set(depName, asDependency({ spec, type }));
145
155
  }
146
156
  }
147
157
  // store in transientAdd if there are any dependencies
@@ -31,18 +31,26 @@ export const removeSatisfiedSpecs = ({ add, graph, modifiers, }) => {
31
31
  const depIsCatalog = dependency.spec.type === 'catalog';
32
32
  if (edgeIsCatalog !== depIsCatalog)
33
33
  continue;
34
+ // a governed edge is checked against the modifier value (e.g.
35
+ // `workspace:*` to a versionless workspace), not its own spec: one
36
+ // built before the modifier applied must be rebuilt
37
+ const mod = modifiers?.importerEdgeModifier(importer, dependency.spec);
34
38
  // If the current graph edge is already valid, then we remove that
35
39
  // dependency item from the list of items to be added to the graph
36
- if (satisfies(edge.to?.id, dependency.spec, edge.from.location, graph.projectRoot, graph.monorepo)) {
37
- // a governed edge carries the modifier value by construction:
38
- // healing it to the manifest text would only be undone by the
39
- // rebuild that re-applies the override. `implicit` is not a real
40
- // edge type, it means "keep what the edge already is", so it
41
- // never makes an edge stale
42
- if ((edge.spec.bareSpec !== dependency.spec.bareSpec ||
43
- (dependency.type !== 'implicit' &&
44
- edge.type !== dependency.type)) &&
45
- !modifiers?.targetsImporterEdge(importer, dependency.spec)) {
40
+ if (satisfies(edge.to?.id, mod?.type === 'edge' ? mod.spec : dependency.spec, edge.from.location, graph.projectRoot, graph.monorepo)) {
41
+ // `implicit` is not a real edge type, it means "keep what the
42
+ // edge already is", so it never makes an edge stale
43
+ const typeChanged = dependency.type !== 'implicit' &&
44
+ edge.type !== dependency.type;
45
+ if (mod) {
46
+ // keeps the modifier value, the rebuild would re-apply it
47
+ // anyway; only the type follows the manifest
48
+ if (typeChanged) {
49
+ staleSpecs.set(edge, { ...dependency, spec: edge.spec });
50
+ }
51
+ }
52
+ else if (edge.spec.bareSpec !== dependency.spec.bareSpec ||
53
+ typeChanged) {
46
54
  staleSpecs.set(edge, dependency);
47
55
  }
48
56
  dependencies.delete(name);
package/dist/index.d.ts CHANGED
@@ -15,6 +15,9 @@ export * from './update.ts';
15
15
  export * from './diff.ts';
16
16
  export * from './modifiers.ts';
17
17
  export * from './virtual-root.ts';
18
+ export type { DiscoveredSkills, LinkSkillsOptions, LinkSkillsResult, ListSkillsResult, MountResult, Skill, SkillFrontmatter, SkillMount, SkillRow, SkillsDir, SkillsOptions, UnmatchedPattern, UnscannedSkills, } from './skills/index.ts';
19
+ export { hasSkills, linkSkills, listSkills } from './skills/lazy.ts';
20
+ export { assertQuery } from './filter-nodes-by-query.ts';
18
21
  import type { LoadOptions as ActualLoadOptions } from './actual/load.ts';
19
22
  export declare const actual: {
20
23
  load: (options: ActualLoadOptions) => import("./graph.ts").Graph;
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ export * from "./update.js";
15
15
  export * from "./diff.js";
16
16
  export * from "./modifiers.js";
17
17
  export * from "./virtual-root.js";
18
+ export { hasSkills, linkSkills, listSkills } from "./skills/lazy.js";
19
+ export { assertQuery } from "./filter-nodes-by-query.js";
18
20
  import { load as actualLoad } from "./actual/load.js";
19
21
  export const actual = { load: actualLoad };
20
22
  import { load as lockfileLoad, loadData as lockfileLoadData, } from "./lockfile/load.js";