@vibe-agent-toolkit/agent-skills 0.1.41-rc.8 → 0.1.41

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 (51) hide show
  1. package/dist/files-config.d.ts +219 -29
  2. package/dist/files-config.d.ts.map +1 -1
  3. package/dist/files-config.js +434 -131
  4. package/dist/files-config.js.map +1 -1
  5. package/dist/index.d.ts +4 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +8 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/post-build-checks.d.ts +10 -1
  10. package/dist/post-build-checks.d.ts.map +1 -1
  11. package/dist/post-build-checks.js +55 -6
  12. package/dist/post-build-checks.js.map +1 -1
  13. package/dist/skill-packager.d.ts.map +1 -1
  14. package/dist/skill-packager.js +128 -20
  15. package/dist/skill-packager.js.map +1 -1
  16. package/dist/validators/agent-instruction-presence.d.ts +41 -0
  17. package/dist/validators/agent-instruction-presence.d.ts.map +1 -0
  18. package/dist/validators/agent-instruction-presence.js +79 -0
  19. package/dist/validators/agent-instruction-presence.js.map +1 -0
  20. package/dist/validators/index.d.ts +1 -0
  21. package/dist/validators/index.d.ts.map +1 -1
  22. package/dist/validators/index.js +1 -0
  23. package/dist/validators/index.js.map +1 -1
  24. package/dist/validators/packaging-validator.d.ts +1 -1
  25. package/dist/validators/packaging-validator.d.ts.map +1 -1
  26. package/dist/validators/packaging-validator.js +18 -4
  27. package/dist/validators/packaging-validator.js.map +1 -1
  28. package/dist/validators/rule-engine/rule-context.d.ts +4 -2
  29. package/dist/validators/rule-engine/rule-context.d.ts.map +1 -1
  30. package/dist/validators/rule-engine/rule-context.js.map +1 -1
  31. package/dist/validators/rule-engine/rule-engine.d.ts.map +1 -1
  32. package/dist/validators/rule-engine/rule-engine.js +15 -3
  33. package/dist/validators/rule-engine/rule-engine.js.map +1 -1
  34. package/dist/validators/skill-validator.d.ts.map +1 -1
  35. package/dist/validators/skill-validator.js +14 -8
  36. package/dist/validators/skill-validator.js.map +1 -1
  37. package/dist/validators/source-only-codes.d.ts.map +1 -1
  38. package/dist/validators/source-only-codes.js +1 -0
  39. package/dist/validators/source-only-codes.js.map +1 -1
  40. package/dist/validators/validation-rules.d.ts +93 -3
  41. package/dist/validators/validation-rules.d.ts.map +1 -1
  42. package/dist/validators/validation-rules.js +139 -17
  43. package/dist/validators/validation-rules.js.map +1 -1
  44. package/dist/validators/walker-to-issues.d.ts.map +1 -1
  45. package/dist/validators/walker-to-issues.js +2 -0
  46. package/dist/validators/walker-to-issues.js.map +1 -1
  47. package/dist/walk-link-graph.d.ts +1 -1
  48. package/dist/walk-link-graph.d.ts.map +1 -1
  49. package/dist/walk-link-graph.js +55 -6
  50. package/dist/walk-link-graph.js.map +1 -1
  51. package/package.json +5 -5
@@ -10,8 +10,80 @@
10
10
  import { existsSync, statSync } from 'node:fs';
11
11
  import { copyFile, mkdir } from 'node:fs/promises';
12
12
  import { dirname } from 'node:path';
13
- import { fileContentHash, globMagicRemainder, hasParentTraversalSegment, isGlob, safePath, staticGlobBase, toForwardSlash, } from '@vibe-agent-toolkit/utils';
13
+ import { fileContentHash, globMagicRemainder, hasParentTraversalSegment, isGlob, issueLocation, safePath, staticGlobBase, toForwardSlash, } from '@vibe-agent-toolkit/utils';
14
14
  import { glob } from 'glob';
15
+ import picomatch from 'picomatch';
16
+ import { materializeIssue } from './validators/rule-engine/index.js';
17
+ import { isNeverPackagedBasename } from './validators/validation-rules.js';
18
+ /**
19
+ * Partition a glob's matches into the files that may be packaged and the
20
+ * never-packaged ones it happened to catch.
21
+ *
22
+ * Only globs come through here. Naming `source: extras/README.md` explicitly is an
23
+ * unambiguous instruction to ship that file, and it is honored; a glob is a net,
24
+ * not a declaration, so it does not get to launder the exemption that an explicit
25
+ * declaration earns. Without this split, link-following (which already refuses
26
+ * these files) and `files:` expansion disagree about what belongs in a bundle —
27
+ * and the glob's copy wins silently.
28
+ */
29
+ function partitionNeverPackaged(matches) {
30
+ const kept = [];
31
+ const dropped = [];
32
+ for (const rel of matches) {
33
+ const basename = rel.slice(rel.lastIndexOf('/') + 1);
34
+ // THE shared matcher, not a local `Set.has` — a raw set is case-sensitive, and
35
+ // `Claude.md` satisfies Claude Code's project-local lookup exactly as
36
+ // `CLAUDE.md` does on APFS/NTFS, so a case-sensitive check leaves the harm
37
+ // reachable by renaming one letter.
38
+ (isNeverPackagedBasename(basename) ? dropped : kept).push(rel);
39
+ }
40
+ return { kept, dropped };
41
+ }
42
+ /**
43
+ * Turn never-package drops into reportable findings.
44
+ *
45
+ * Lives here rather than in the packager because this module owns the concept:
46
+ * the drop and the issue describing it must not be able to disagree.
47
+ *
48
+ * Anchored at the SOURCE file, in ONE coordinate system for both the location
49
+ * and the message. The finding used to name the would-be `dest` in both, which
50
+ * meant the only path it gave you was an output path nothing was ever written to
51
+ * — so the issue list mixed output coordinates (this code) with source
52
+ * coordinates (every other pre-build code) and an adopter could not open the
53
+ * thing that went missing. The glob pattern stays in the message because it
54
+ * answers the second question ("which entry caught this?"), but it is no longer
55
+ * the only identifiable thing in the finding.
56
+ *
57
+ * @param locationRoot The ONE base this run anchors locations at. Required, not
58
+ * defaulted to the project root: a defaulted anchor is how a multi-project run
59
+ * silently emits paths relative to the wrong tree.
60
+ */
61
+ export function droppedGlobMatchesToIssues(dropped, locationRoot) {
62
+ return dropped.map((drop) => {
63
+ const file = issueLocation(drop.absFile, locationRoot);
64
+ return materializeIssue('FILES_GLOB_DROPPED_NEVER_PACKAGED', {
65
+ location: file,
66
+ detail: `${file} matched by files: source '${drop.source}'`,
67
+ });
68
+ });
69
+ }
70
+ /**
71
+ * An absolute path spelled relative to the run's anchor, for a message or a
72
+ * location a consumer reads.
73
+ *
74
+ * `issueLocation` returns `''` when the path IS the anchor root (a
75
+ * project-root-wide glob such as `**\/*`); `.` is that same directory spelled
76
+ * as a path a reader can act on.
77
+ *
78
+ * Load-bearing for every THROWN message in this module, not only for issue
79
+ * locations: `applyFilesConfig`'s throws reach `vat skills build`'s stdout
80
+ * verbatim as `failedSkills[].message`, so an absolute path there publishes the
81
+ * developer's home directory into whatever issue or CI log the report is pasted
82
+ * into. Build-report messages are project-relative, never absolute.
83
+ */
84
+ function anchoredPath(absolute, root) {
85
+ return issueLocation(absolute, root) || '.';
86
+ }
15
87
  /**
16
88
  * Returns a hint clause (with a leading space) when `source` looks like a build
17
89
  * artifact that hasn't been produced yet, otherwise returns `''`.
@@ -50,6 +122,40 @@ export function normalizeRelPath(p) {
50
122
  }
51
123
  return normalized;
52
124
  }
125
+ /**
126
+ * The skill-output-relative dest a GLOB `files:` entry gives one absolute source
127
+ * path, or `undefined` when the entry's expansion would not include it.
128
+ *
129
+ * Answers, without running the glob, the question the packager must settle BEFORE
130
+ * anything is copied: "does the config already declare where this file goes?" A
131
+ * glob's expansion is late-bound to copy time ({@link copyGlobEntry}), so the path
132
+ * map used to have no entry for its matches — and a match that link traversal ALSO
133
+ * found was therefore dropped at its type-derived location while the glob copied
134
+ * it to the declared dest. Identical bytes shipped twice, the rewritten link
135
+ * pointed at traversal's copy, and the declared `dest:` was dead. One file, one
136
+ * dest: the declaration wins, and this is how the path map learns it.
137
+ *
138
+ * The three gates below are exactly {@link copyGlobEntry}'s own, restated as a
139
+ * predicate so the dest computed here is the dest the copy actually writes:
140
+ * under the static base, matching the magic remainder (`dot: true`, as the copy
141
+ * expands), and not refused by {@link partitionNeverPackaged}. A never-packaged
142
+ * match has NO dest — re-pointing the path map at one would rewrite a link to a
143
+ * file that never arrives.
144
+ */
145
+ export function globEntryDest(entry, projectRoot, absSource) {
146
+ if (!isGlob(entry.source))
147
+ return undefined;
148
+ const absoluteBase = toForwardSlash(safePath.resolve(safePath.join(projectRoot, staticGlobBase(entry.source))));
149
+ const abs = toForwardSlash(safePath.resolve(absSource));
150
+ if (!abs.startsWith(absoluteBase + '/'))
151
+ return undefined;
152
+ const rel = abs.slice(absoluteBase.length + 1);
153
+ if (!picomatch.isMatch(rel, globMagicRemainder(entry.source), { dot: true }))
154
+ return undefined;
155
+ if (isNeverPackagedBasename(rel.slice(rel.lastIndexOf('/') + 1)))
156
+ return undefined;
157
+ return normalizeRelPath(safePath.join(entry.dest, rel));
158
+ }
53
159
  /**
54
160
  * Merge defaults and per-skill files entries.
55
161
  *
@@ -98,52 +204,23 @@ export function mergeFilesConfig(defaults, perSkill) {
98
204
  return merged;
99
205
  }
100
206
  /**
101
- * Match a link target path against files config entries.
207
+ * The skill-output-relative dests named by EXPLICIT (non-glob) `files:` entries.
102
208
  *
103
- * Returns the matching entry and whether it matched on source or dest.
104
- * Source matches take priority over dest matches.
209
+ * THE definition of "the config declared this output path", shared by every lane
210
+ * that has to decide whether a file in a bundle is there on purpose. Naming a
211
+ * `source`/`dest` pair is an unambiguous instruction to ship that file; a GLOB
212
+ * never named the file it caught, so its expansion is deliberately absent from
213
+ * this list — a net is not a declaration. (`walk-link-graph.ts`'s
214
+ * `refusesAgentInstructionFile` draws the same line on the link side, and
215
+ * {@link partitionNeverPackaged} on the copy side; all three must agree or the
216
+ * mechanisms disagree about one bundle.)
105
217
  *
106
- * For GLOB entries (`isGlob(entry.source)`), matching is by directory prefix:
107
- * - source match: link is equal to or under the glob's static base
108
- * - dest match: link is equal to or under entry.dest
109
- *
110
- * Single-file entries still match EXACTLY (unchanged).
111
- *
112
- * @param linkTarget - Resolved link target path (relative to project root)
113
- * @param files - Merged files config entries
114
- * @returns Match result or null if no match
218
+ * Spelled via {@link normalizeRelPath}, which is the spelling a packaged file's
219
+ * output-relative path is computed with — so membership can be tested by exact
220
+ * equality, never by a prefix test.
115
221
  */
116
- export function matchLinkToFiles(linkTarget, files) {
117
- const normalized = normalizeRelPath(linkTarget);
118
- /** True when `candidate` equals `normalized` or is a path-prefix of it. */
119
- function isPrefixMatch(candidate) {
120
- return normalized === candidate || normalized.startsWith(candidate + '/');
121
- }
122
- // Source match has priority (checked before dest across all entries)
123
- for (const entry of files) {
124
- if (isGlob(entry.source)) {
125
- const base = normalizeRelPath(staticGlobBase(entry.source));
126
- if (isPrefixMatch(base)) {
127
- return { match: 'source', entry };
128
- }
129
- }
130
- else if (normalizeRelPath(entry.source) === normalized) {
131
- return { match: 'source', entry };
132
- }
133
- }
134
- // Then check dest match
135
- for (const entry of files) {
136
- if (isGlob(entry.source)) {
137
- const destBase = normalizeRelPath(entry.dest);
138
- if (isPrefixMatch(destBase)) {
139
- return { match: 'dest', entry };
140
- }
141
- }
142
- else if (normalizeRelPath(entry.dest) === normalized) {
143
- return { match: 'dest', entry };
144
- }
145
- }
146
- return null;
222
+ export function explicitFilesConfigDests(files) {
223
+ return files.filter((entry) => !isGlob(entry.source)).map((entry) => normalizeRelPath(entry.dest));
147
224
  }
148
225
  /**
149
226
  * Verify that each (absSource, absDest) pair has byte-identical content.
@@ -178,16 +255,20 @@ export function verifyFilesIntegrity(pairs) {
178
255
  * diffing against the expected rel set is what catches a misrouted rebase, a
179
256
  * stale leftover, or a dropped file.
180
257
  *
181
- * SAFETY ASSUMPTION (why this can't false-positive): a glob entry OWNS its dest
182
- * directory (`skillOutputDir/<entry.dest>`) and the build wipes the skill output
183
- * dir before copying (skill-packager.ts removes `resolvedOutput` recursively
184
- * before any copy). Nothing else writes into this subtree, so any EXTRA file is a
185
- * genuine bug, not a co-tenant. This scoping is what makes the set check safe
186
- * here even though a project-wide "no extra files" check would not be.
258
+ * SAFETY ASSUMPTION (why this can't false-positive): a glob entry does NOT own
259
+ * its dest directory outright — another `files:` entry may legitimately declare a
260
+ * dest inside the same subtree, and that is exactly how the documented escape
261
+ * hatch works (an explicit `source: extras/README.md` re-ships a file the glob
262
+ * refused). So `expectedRel` is the union of what THIS entry copied and every
263
+ * other entry's declared dest under the same subtree, and the caller runs this
264
+ * only after ALL entries have copied — see {@link applyFilesConfig}. What is left
265
+ * over is a file no entry accounts for: a genuine bug, not a co-tenant. (The build
266
+ * wipes the skill output dir before copying — skill-packager.ts removes
267
+ * `resolvedOutput` recursively — so there are no stale files either.)
187
268
  *
188
269
  * @param destDir Absolute path to the glob entry's dest subtree.
189
- * @param expectedRel Forward-slash rel paths (relative to `destDir`) the copy
190
- * step wrote for THIS entry.
270
+ * @param expectedRel Forward-slash rel paths (relative to `destDir`) that the
271
+ * whole `files:` config accounts for inside this subtree.
191
272
  * @param source The entry's `source` (for error messages only).
192
273
  * @throws if the actual subtree omits an expected file or contains an extra one.
193
274
  */
@@ -217,10 +298,10 @@ export async function verifyDestSet(destDir, expectedRel, source) {
217
298
  * Returns `[destPath]` (the single entry.dest string) on success.
218
299
  * Throws on missing source or if source is a directory.
219
300
  */
220
- async function copyNonGlobEntry(entry, absoluteSource, skillOutputDir) {
301
+ async function copyNonGlobEntry(entry, absoluteSource, projectRoot, skillOutputDir) {
221
302
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- source path from validated config
222
303
  if (!existsSync(absoluteSource)) {
223
- throw new Error(`files: source '${entry.source}' does not exist (resolved to ${absoluteSource}).${buildArtifactHint(entry.source)}`);
304
+ throw new Error(`files: source '${entry.source}' does not exist (resolved to ${anchoredPath(absoluteSource, projectRoot)}).${buildArtifactHint(entry.source)}`);
224
305
  }
225
306
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- source path from validated config
226
307
  if (statSync(absoluteSource).isDirectory()) {
@@ -234,6 +315,52 @@ async function copyNonGlobEntry(entry, absoluteSource, skillOutputDir) {
234
315
  await copyFile(absoluteSource, absoluteDest);
235
316
  return { relDest: normalizeRelPath(entry.dest), absSource: absoluteSource, absDest: absoluteDest };
236
317
  }
318
+ /**
319
+ * THE expansion of one GLOB `files:` entry — the single answer to "which files does
320
+ * this pattern name, and which of them never ship?".
321
+ *
322
+ * Extracted so the copy ({@link copyGlobEntry}) and the pre-build gates
323
+ * ({@link collectDroppedGlobMatches}) cannot expand the same glob two ways. A
324
+ * second expansion that disagrees with the copy's is exactly how a lane comes to
325
+ * report a bundle that was never produced.
326
+ *
327
+ * Deliberately reports NO verdict: zero matches is a fact here, and only the caller
328
+ * knows whether it is a failure (the copy, after a build) or the expected state (a
329
+ * validate run before one).
330
+ *
331
+ * @throws if the glob's magic remainder contains a `..` segment — `glob` honors it
332
+ * and climbs above `absoluteBase`, so the pattern cannot be expanded safely at
333
+ * any phase. (The static base may legitimately contain a leading `..`: that is
334
+ * the deliberate sibling-base monorepo feature.)
335
+ */
336
+ async function expandGlobEntry(entry, projectRoot) {
337
+ const remainder = globMagicRemainder(entry.source);
338
+ if (hasParentTraversalSegment(remainder)) {
339
+ throw new Error(`files: source '${entry.source}' (glob) has a '..' segment in its glob portion ('${remainder}'); ` +
340
+ `parent-directory traversal is not allowed after the static base.`);
341
+ }
342
+ const absoluteBase = safePath.resolve(safePath.join(projectRoot, staticGlobBase(entry.source)));
343
+ // dot: true so hidden files under the source subtree are included — keeps the
344
+ // package symmetric with verifyDestSet (M10: silent dot-file drop).
345
+ const rawMatches = await glob(remainder, { cwd: absoluteBase, nodir: true, dot: true });
346
+ const allMatches = rawMatches.map((m) => toForwardSlash(m)).sort((a, b) => a.localeCompare(b));
347
+ const { kept, dropped } = partitionNeverPackaged(allMatches);
348
+ return {
349
+ absoluteBase,
350
+ allMatches,
351
+ kept,
352
+ droppedRel: dropped,
353
+ dropped: dropped.map((rel) => ({
354
+ source: entry.source,
355
+ // The refused file itself. Computed HERE, where the base a match is
356
+ // relative to is known for certain, rather than re-derived by each
357
+ // consumer from `dest` — a dest cannot be inverted back to a source once
358
+ // the entry has rebased it.
359
+ absFile: toForwardSlash(safePath.joinUnderRoot(absoluteBase, rel)),
360
+ dest: normalizeRelPath(safePath.join(entry.dest, rel)),
361
+ })),
362
+ };
363
+ }
237
364
  /**
238
365
  * Expand a glob entry, copy all matched files, and return copied rel-dest paths
239
366
  * plus source/dest pairs for optional integrity verification.
@@ -253,22 +380,24 @@ async function copyNonGlobEntry(entry, absoluteSource, skillOutputDir) {
253
380
  * complete and makes integrity mean what it says.
254
381
  */
255
382
  async function copyGlobEntry(entry, projectRoot, skillOutputDir) {
256
- const base = staticGlobBase(entry.source);
257
- const remainder = globMagicRemainder(entry.source);
258
- // H2: the static base may legitimately contain leading '..' (the deliberate
259
- // sibling-base monorepo feature), but the MAGIC REMAINDER must never contain a
260
- // '..' segment — `glob` honors it and climbs above absoluteBase. Reject it.
261
- if (hasParentTraversalSegment(remainder)) {
262
- throw new Error(`files: source '${entry.source}' (glob) has a '..' segment in its glob portion ('${remainder}'); ` +
263
- `parent-directory traversal is not allowed after the static base.`);
383
+ const { absoluteBase, allMatches, kept: matches, droppedRel, dropped } = await expandGlobEntry(entry, projectRoot);
384
+ // Project-relative in both throws below: these messages are published verbatim
385
+ // as `failedSkills[].message` (see {@link anchoredPath}).
386
+ const reportedBase = anchoredPath(absoluteBase, projectRoot);
387
+ if (allMatches.length === 0) {
388
+ throw new Error(`files: source '${entry.source}' (glob) matched no files under ${reportedBase} — has your build run?`);
264
389
  }
265
- const absoluteBase = safePath.resolve(safePath.join(projectRoot, base));
266
- // dot: true so hidden files under the source subtree are included — keeps the
267
- // package symmetric with verifyDestSet (M10: silent dot-file drop).
268
- const rawMatches = await glob(remainder, { cwd: absoluteBase, nodir: true, dot: true });
269
- const matches = rawMatches.map((m) => toForwardSlash(m)).sort((a, b) => a.localeCompare(b));
390
+ // Distinct from the zero-match case above: the build DID run and the glob DID
391
+ // match — every match is simply a file that never ships. Saying "has your build
392
+ // run?" here would send the author hunting a build failure that isn't there.
270
393
  if (matches.length === 0) {
271
- throw new Error(`files: source '${entry.source}' (glob) matched no files under ${absoluteBase} — has your build run?`);
394
+ throw new Error(`files: source '${entry.source}' (glob) matched ${allMatches.length} file(s) under ${reportedBase}, ` +
395
+ `but all of them are never packaged into a skill bundle: ${droppedRel.join(', ')}. ` +
396
+ // NOT "widen the glob": the filter is on basename and applies at any width, so a
397
+ // wider glob clears this error while still shipping none of these files — advice
398
+ // that looks like it worked and silently does not.
399
+ `Declare an explicit source: entry for a file you intend to ship, ` +
400
+ `or point the glob at a directory that holds files which can be packaged.`);
272
401
  }
273
402
  const copied = [];
274
403
  const pairs = [];
@@ -290,63 +419,182 @@ async function copyGlobEntry(entry, projectRoot, skillOutputDir) {
290
419
  pairs.push({ absSource, absDest });
291
420
  rels.push(toForwardSlash(rel));
292
421
  }
293
- return { copied, pairs, rels };
422
+ return { copied, pairs, rels, dropped };
294
423
  }
295
424
  /**
296
- * Copy each `files:` entry's `source` → `dest` into the skill output directory.
297
- *
298
- * This is the single copy primitive shared by every build path so that
299
- * build-provided artifacts (a bundled engine, generated data, a catalog) are
300
- * VAT-managed end-to-end: the shared-pool `vat skills build` packager and the
301
- * Claude plugin marketplace build both call it, instead of the latter relying on
302
- * an external inject script VAT can't see. `source` is resolved the same way the
303
- * packager does (`resolve(join(projectRoot, source))`, so an absolute-looking
304
- * source roots UNDER the project).
305
- *
306
- * Returns every skill-output-relative dest path this config accounts for, one per
307
- * FILE (glob entries expanded), normalized via {@link normalizeRelPath} so each
308
- * value compares equal to the path `checkUnreferencedFiles` computes for the
309
- * packaged file. Callers must pass this to the post-build orphan check: a dest
310
- * declared in `files:` is proof of intent, and a lane that doesn't know the list
311
- * flags VAT's own copies as files the author forgot to document.
312
- *
313
- * ONE invariant, both spellings: when this returns, every file the config matched
314
- * exists at its declared dest and is reported. A source already materialized by
315
- * link traversal changes nothing about that answer — it is declared either way,
316
- * and whether traversal or this copy put the bytes there is an ordering accident.
317
- * Only the mechanism differs: a non-glob entry's bundled copy already sits AT
318
- * `entry.dest` (the packager re-points the path map for it), so its copy is
319
- * skipped as redundant; a glob entry's does not, so it is copied regardless — see
320
- * {@link copyGlobEntry}.
321
- *
322
- * Glob entries (`source` containing `*`, `?`, or `[`) expand late-bound at
323
- * copy time: all matched files are rebased under `dest` preserving their
324
- * path relative to the glob's static base. Zero matches → error.
325
- *
326
- * When `integrity: true` is set on an entry, `verifyFilesIntegrity` is called
327
- * after copying to assert byte-identical content. For GLOB entries it ALSO runs
328
- * `verifyDestSet` to assert the dest subtree contains exactly the copied rels
329
- * (no missing, no extra) — catching a misrouted rebase the byte check misses.
330
- * Single-file entries get only the byte check (dest is a file, not a subtree).
331
- *
332
- * @throws if a declared `source` does not exist — a declared build artifact must
333
- * be present at copy time (callers that defer existence validate it upstream).
425
+ * What the project's GLOB `files:` entries would do, without copying anything.
426
+ *
427
+ * The pre-build half of {@link applyFilesConfig}: `vat skills validate` and
428
+ * `vat audit` can answer "what will this config fail to ship?" before a build
429
+ * exists, and they answer it by running THE expansion the copy runs
430
+ * ({@link expandGlobEntry}) — a second, independently written expansion would be
431
+ * free to disagree with the copy about what ships, which is the whole defect class
432
+ * this module exists to close.
433
+ *
434
+ * Non-throwing by construction, unlike {@link copyGlobEntry}: a pre-build gate runs
435
+ * before the artifact exists, so a glob over an unbuilt `dist/` matching nothing is
436
+ * the expected state, not a failure. `copyGlobEntry` still raises there, where the
437
+ * build HAS run and zero matches is real.
438
+ *
439
+ * That reasoning settles the SEVERITY of a failing expansion, not whether to
440
+ * mention it — and this used to return only `dropped`, so the gate reported the
441
+ * drop that is harmless by design (a glob is a net) and said nothing about either
442
+ * expansion outcome that is certain to fail the very next command. `unmatched`
443
+ * and `allRefused` end that silence, graded `info` and `warning` respectively (see
444
+ * FILES_GLOB_MATCHED_NOTHING / FILES_GLOB_MATCHED_ONLY_NEVER_PACKAGED).
445
+ *
446
+ * The three buckets partition the entries as two of `copyGlobEntry`'s three throws
447
+ * plus its success path do — same predicates, same order, one expansion. Its THIRD
448
+ * throw (`expandGlobEntry` refusing a `..` segment in the magic remainder) has no
449
+ * bucket, and the skip below explains why — but the consequence belongs here: this
450
+ * function's answer to "what will this config fail to ship?" is silent for that one
451
+ * malformed shape, which is the same class of silence `unmatched`/`allRefused` were
452
+ * added to end. Closing it needs a code of its own (a wrong-shaped PATTERN, not an
453
+ * unbuilt or unshippable artifact), or a `source` refine on `SkillFileEntrySchema`
454
+ * that rejects the pattern at config load and makes the skip unreachable.
455
+ *
456
+ * Drops re-shipped by an explicit entry are filtered out for the same reason
457
+ * {@link applyFilesConfig} filters them: reporting "it did not ship" about a file
458
+ * the documented escape hatch puts in the bundle tells an author their remediation
459
+ * failed when it worked. That filter is deliberately NOT applied to `allRefused`:
460
+ * `copyGlobEntry` throws on an entry whose own kept-set is empty no matter what
461
+ * any other entry ships, so filtering it would predict a green build that fails.
334
462
  */
335
- /** Copy + optionally integrity-check one GLOB `files:` entry; returns its declared rel-dests. */
336
- async function applyGlobFileEntry(fileEntry, opts) {
337
- const { copied, pairs, rels } = await copyGlobEntry(fileEntry, opts.projectRoot, opts.skillOutputDir);
338
- if (fileEntry.integrity === true) {
339
- verifyFilesIntegrity(pairs);
340
- // Scoped set check: the glob entry owns its dest subtree and the build wiped
341
- // the output dir first, so the on-disk subtree must equal exactly the rels we
342
- // copied — catches a misrouted rebase the pair-hash misses.
343
- await verifyDestSet(safePath.joinUnderRoot(opts.skillOutputDir, fileEntry.dest), rels, fileEntry.source);
463
+ export async function collectPreBuildGlobFindings(filesConfig, projectRoot) {
464
+ const shippedDests = new Set(explicitFilesConfigDests(filesConfig));
465
+ const dropped = [];
466
+ const allRefused = [];
467
+ const unmatched = [];
468
+ for (const entry of filesConfig) {
469
+ if (!isGlob(entry.source))
470
+ continue;
471
+ // A '..' segment in the magic remainder is a malformed pattern that
472
+ // `expandGlobEntry` refuses to expand. It is the BUILD's error to raise (and
473
+ // `copyGlobEntry` does); a pre-build gate asking "what would this do?" must not
474
+ // abort the whole run over it — and it must not put the entry in either failure
475
+ // bucket, which would name a cause (an unbuilt or unshippable artifact) that is
476
+ // not the one.
477
+ //
478
+ // KNOWN GAP, stated rather than implied by a `continue`: this skip is total, so
479
+ // the entry produces NO finding in any pre-build lane and the author first hears
480
+ // about it from a failed build. Not "misreported", but not reported either. The
481
+ // honest fix is a distinct code for a wrong-shaped pattern, or rejecting the
482
+ // pattern in `SkillFileEntrySchema.source` the way `dest` already is — at which
483
+ // point this line becomes defense-in-depth against a config that cannot load.
484
+ if (hasParentTraversalSegment(globMagicRemainder(entry.source)))
485
+ continue;
486
+ const expansion = await expandGlobEntry(entry, projectRoot);
487
+ const absBase = toForwardSlash(expansion.absoluteBase);
488
+ // The two predicates below are `copyGlobEntry`'s two throws, in its order and
489
+ // keyed on its fields: zero total matches first, then zero SURVIVING matches.
490
+ // Keeping them distinct is what stops a wholly-refused glob from being
491
+ // reported as an unbuilt one — the build says "has your build run?" only in
492
+ // the first case, and sending an author to re-run a build that already ran is
493
+ // exactly the misdirection the second error was written to avoid.
494
+ if (expansion.allMatches.length === 0) {
495
+ unmatched.push({ source: entry.source, absBase });
496
+ continue;
497
+ }
498
+ if (expansion.kept.length === 0) {
499
+ // Supersedes this entry's per-file drops rather than adding to them: the
500
+ // build raises ONE error listing every refused file, and reporting the same
501
+ // cause again at file granularity would double-count a single defect. The
502
+ // file names are preserved on `absRefused`.
503
+ allRefused.push({
504
+ source: entry.source,
505
+ absBase,
506
+ absRefused: expansion.dropped.map((d) => d.absFile),
507
+ });
508
+ continue;
509
+ }
510
+ dropped.push(...expansion.dropped.filter((d) => !shippedDests.has(normalizeRelPath(d.dest))));
344
511
  }
345
- return copied;
512
+ return { dropped, allRefused, unmatched };
513
+ }
514
+ /**
515
+ * Turn {@link collectPreBuildGlobFindings} into reportable pre-build issues.
516
+ *
517
+ * THE emitter for the pre-build gates, so a lane cannot pick up one half of the
518
+ * findings and quietly drop the other — which is how the zero-match went
519
+ * unreported for as long as it did.
520
+ *
521
+ * @param locationRoot The ONE base this run anchors locations at (see
522
+ * {@link droppedGlobMatchesToIssues}).
523
+ */
524
+ export function preBuildGlobFindingsToIssues(findings, locationRoot) {
525
+ // Both entry-level findings anchor at the glob's static base: they are about an
526
+ // ENTRY, not a file, and the base is the only path they have.
527
+ const anchorBase = (absBase) => anchoredPath(absBase, locationRoot);
528
+ return [
529
+ ...droppedGlobMatchesToIssues(findings.dropped, locationRoot),
530
+ ...findings.allRefused.map((entry) => {
531
+ const base = anchorBase(entry.absBase);
532
+ // Every refused file, in the SAME source coordinates the drop findings use —
533
+ // this issue is the only surviving report of those names.
534
+ const refused = entry.absRefused.map((f) => issueLocation(f, locationRoot)).join(', ');
535
+ return materializeIssue('FILES_GLOB_MATCHED_ONLY_NEVER_PACKAGED', {
536
+ location: base,
537
+ detail: `files: source '${entry.source}' matched ${entry.absRefused.length} file(s) under ${base}, ` +
538
+ `all never packaged: ${refused}`,
539
+ });
540
+ }),
541
+ ...findings.unmatched.map((entry) => {
542
+ const base = anchorBase(entry.absBase);
543
+ return materializeIssue('FILES_GLOB_MATCHED_NOTHING', {
544
+ location: base,
545
+ detail: `files: source '${entry.source}' expands under ${base}`,
546
+ });
547
+ }),
548
+ ];
549
+ }
550
+ /** Copy one GLOB `files:` entry. */
551
+ async function applyGlobFileEntry(fileEntry, opts) {
552
+ const { copied, pairs, rels, dropped } = await copyGlobEntry(fileEntry, opts.projectRoot, opts.skillOutputDir);
553
+ return {
554
+ dests: copied,
555
+ dropped,
556
+ pending: fileEntry.integrity === true ? { entry: fileEntry, pairs, rels } : undefined,
557
+ };
346
558
  }
347
- /** Copy + optionally integrity-check one NON-GLOB `files:` entry; returns its declared rel-dests. */
559
+ /**
560
+ * Reject a NON-GLOB `dest` that names a directory rather than a file.
561
+ *
562
+ * A non-glob entry is a typed single-file slot: one source, one output path. A
563
+ * `dest` ending in a separator (or spelling the dest root itself) names a
564
+ * directory, and nothing downstream noticed — `copyFile` wrote the source's bytes
565
+ * to a file NAMED `guides`, and the config then accounted for `guides/` while the
566
+ * bundle held `guides`, so the two never compared equal.
567
+ *
568
+ * Worth its own error rather than a silent normalization because of WHAT can ride
569
+ * in: an explicit entry is the one thing that sanctions shipping an
570
+ * agent-instruction file, and at a directory-shaped dest that file arrives under a
571
+ * basename no never-package rule and no presence detector recognizes. Renaming
572
+ * `CLAUDE.md` to `guides` on the author's behalf is not a service.
573
+ *
574
+ * GLOB entries are deliberately exempt: their `dest` IS a subtree root, and every
575
+ * match is joined onto it (`safePath.join('packs/', 'a/x.json')`), which drops the
576
+ * trailing separator on its own.
577
+ */
578
+ function assertFileShapedDest(entry) {
579
+ const normalized = normalizeRelPath(entry.dest);
580
+ if (normalized !== '' && normalized !== '.' && !normalized.endsWith('/'))
581
+ return;
582
+ let base = normalized;
583
+ while (base.endsWith('/'))
584
+ base = base.slice(0, -1);
585
+ const suggested = `${base}/${entry.source.slice(entry.source.lastIndexOf('/') + 1)}`;
586
+ throw new Error(`files: dest '${entry.dest}' names a directory, but a non-glob entry copies ONE file to ONE path. ` +
587
+ `Name the output file itself (e.g. dest: '${normalizeRelPath(suggested)}'), ` +
588
+ `or use a glob source to copy a whole subtree.`);
589
+ }
590
+ /** Copy one NON-GLOB `files:` entry. */
348
591
  async function applyNonGlobFileEntry(fileEntry, opts, bundledFileSet) {
592
+ // Before the bundled-skip branch below, never inside it: which branch an entry
593
+ // takes depends on whether link traversal happened to reach the source first,
594
+ // and a malformed dest must not be validated by an ordering accident.
595
+ assertFileShapedDest(fileEntry);
349
596
  const absoluteSource = safePath.resolve(safePath.join(opts.projectRoot, fileEntry.source));
597
+ const optedIn = fileEntry.integrity === true;
350
598
  if (bundledFileSet.has(toForwardSlash(absoluteSource))) {
351
599
  // Already materialized by link traversal (copied to entry.dest via the path
352
600
  // map) — don't copy it a second time. But a requested `integrity` check must
@@ -354,31 +602,86 @@ async function applyNonGlobFileEntry(fileEntry, opts, bundledFileSet) {
354
602
  // dest is byte-identical to the source, exactly as the copy path below would.
355
603
  // (The bundled copy lands at entry.dest — see applyNonGlobEntriesToPathMap in
356
604
  // skill-packager.ts.)
357
- if (fileEntry.integrity === true) {
358
- const absDest = safePath.joinUnderRoot(opts.skillOutputDir, fileEntry.dest);
359
- verifyFilesIntegrity([{ absSource: absoluteSource, absDest }]);
360
- }
605
+ const absDest = safePath.joinUnderRoot(opts.skillOutputDir, fileEntry.dest);
361
606
  // The dest is still reported: this function answers "which output paths does
362
607
  // `files:` account for," not "which bytes did I move." Whether link traversal
363
608
  // or this copy put the file there is an ordering accident, and callers that
364
609
  // ask "is this packaged file declared?" must not get a different answer for it.
365
- return [normalizeRelPath(fileEntry.dest)];
610
+ return {
611
+ dests: [normalizeRelPath(fileEntry.dest)],
612
+ dropped: [],
613
+ pending: optedIn
614
+ ? { entry: fileEntry, pairs: [{ absSource: absoluteSource, absDest }], rels: undefined }
615
+ : undefined,
616
+ };
366
617
  }
367
- const { relDest, absSource, absDest } = await copyNonGlobEntry(fileEntry, absoluteSource, opts.skillOutputDir);
368
- if (fileEntry.integrity === true) {
369
- verifyFilesIntegrity([{ absSource, absDest }]);
618
+ const { relDest, absSource, absDest } = await copyNonGlobEntry(fileEntry, absoluteSource, opts.projectRoot, opts.skillOutputDir);
619
+ return {
620
+ dests: [relDest],
621
+ dropped: [],
622
+ pending: optedIn ? { entry: fileEntry, pairs: [{ absSource, absDest }], rels: undefined } : undefined,
623
+ };
624
+ }
625
+ /**
626
+ * `dest` expressed relative to the `destRoot` subtree, or `null` when it does not
627
+ * live inside it. Both arguments are skill-output-relative and normalized.
628
+ *
629
+ * Equality returns `null` on purpose: a dest EQUAL to the subtree root names the
630
+ * directory itself, not a file inside it, so it contributes nothing to a file set.
631
+ */
632
+ function relUnderDest(destRoot, dest) {
633
+ if (destRoot === '' || destRoot === '.')
634
+ return dest;
635
+ return dest.startsWith(destRoot + '/') ? dest.slice(destRoot.length + 1) : null;
636
+ }
637
+ /**
638
+ * Run every opted-in integrity check once the whole config has copied.
639
+ *
640
+ * `allDests` is the config's complete accounting, which is what makes a glob's
641
+ * dest-set check order-independent: a sibling entry's dest inside the same subtree
642
+ * is EXPECTED (that is the escape hatch working), while a file no entry declares
643
+ * is still unexpected — so this stays a real assertion about the shipped bundle
644
+ * rather than being weakened into a permissive one.
645
+ */
646
+ async function runDeferredIntegrity(pending, allDests, skillOutputDir) {
647
+ for (const { entry, pairs, rels } of pending) {
648
+ verifyFilesIntegrity(pairs);
649
+ if (rels === undefined)
650
+ continue;
651
+ const destRoot = normalizeRelPath(entry.dest);
652
+ const expected = new Set(rels);
653
+ for (const dest of allDests) {
654
+ const rel = relUnderDest(destRoot, dest);
655
+ if (rel !== null && rel !== '')
656
+ expected.add(rel);
657
+ }
658
+ await verifyDestSet(safePath.joinUnderRoot(skillOutputDir, entry.dest), [...expected], entry.source);
370
659
  }
371
- return [relDest];
372
660
  }
373
661
  export async function applyFilesConfig(opts) {
374
662
  const bundledFileSet = new Set((opts.bundledFiles ?? []).map((f) => toForwardSlash(f)));
375
- const copied = [];
663
+ const dests = [];
664
+ const dropped = [];
665
+ const pending = [];
376
666
  for (const fileEntry of opts.filesConfig) {
377
- const entryCopied = isGlob(fileEntry.source)
667
+ const outcome = isGlob(fileEntry.source)
378
668
  ? await applyGlobFileEntry(fileEntry, opts)
379
669
  : await applyNonGlobFileEntry(fileEntry, opts, bundledFileSet);
380
- copied.push(...entryCopied);
670
+ dests.push(...outcome.dests);
671
+ dropped.push(...outcome.dropped);
672
+ if (outcome.pending)
673
+ pending.push(outcome.pending);
381
674
  }
382
- return copied;
675
+ await runDeferredIntegrity(pending, dests, opts.skillOutputDir);
676
+ // A drop is only worth reporting if the file genuinely did not ship. In the
677
+ // escape-hatch config the guide prescribes — a glob over a subtree plus an
678
+ // explicit entry naming one never-packaged file inside it — the glob drops
679
+ // that dest and the explicit entry writes it. Reporting the drop there would
680
+ // assert "it did not ship" about a file sitting in the bundle, telling an
681
+ // author their documented remediation had failed. Filtering on the FINAL dest
682
+ // set (not on entry order) keeps the finding true whichever order they wrote.
683
+ const shippedDests = new Set(dests.map((d) => normalizeRelPath(d)));
684
+ const reportableDropped = dropped.filter((d) => !shippedDests.has(normalizeRelPath(d.dest)));
685
+ return { dests, dropped: reportableDropped };
383
686
  }
384
687
  //# sourceMappingURL=files-config.js.map