@vibe-agent-toolkit/cli 0.1.41-rc.7 → 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.
- package/dist/bin/vat +24 -1
- package/dist/bin/vat.js +24 -1
- package/dist/bin/vat.js.map +1 -1
- package/dist/bin.js +23 -2
- package/dist/bin.js.map +1 -1
- package/dist/commands/agent/list.d.ts +11 -0
- package/dist/commands/agent/list.d.ts.map +1 -1
- package/dist/commands/agent/list.js +30 -11
- package/dist/commands/agent/list.js.map +1 -1
- package/dist/commands/audit/distributed-tree.d.ts +172 -0
- package/dist/commands/audit/distributed-tree.d.ts.map +1 -0
- package/dist/commands/audit/distributed-tree.js +319 -0
- package/dist/commands/audit/distributed-tree.js.map +1 -0
- package/dist/commands/audit.d.ts +69 -1
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +525 -174
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/build.d.ts +14 -0
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +103 -26
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/git-publish.js +21 -3
- package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
- package/dist/commands/claude/marketplace/validate.d.ts +125 -0
- package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/validate.js +251 -44
- package/dist/commands/claude/marketplace/validate.js.map +1 -1
- package/dist/commands/claude/org/stubs.d.ts +9 -1
- package/dist/commands/claude/org/stubs.d.ts.map +1 -1
- package/dist/commands/claude/org/stubs.js +11 -3
- package/dist/commands/claude/org/stubs.js.map +1 -1
- package/dist/commands/claude/plugin/build.d.ts +34 -4
- package/dist/commands/claude/plugin/build.d.ts.map +1 -1
- package/dist/commands/claude/plugin/build.js +187 -16
- package/dist/commands/claude/plugin/build.js.map +1 -1
- package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
- package/dist/commands/claude/plugin/plugin-files.js +6 -2
- package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
- package/dist/commands/claude/plugin/plugin-json-merge.d.ts.map +1 -1
- package/dist/commands/claude/plugin/plugin-json-merge.js +15 -0
- package/dist/commands/claude/plugin/plugin-json-merge.js.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.d.ts +40 -0
- package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.js +100 -4
- package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
- package/dist/commands/corpus/report.d.ts +22 -4
- package/dist/commands/corpus/report.d.ts.map +1 -1
- package/dist/commands/corpus/report.js +1 -0
- package/dist/commands/corpus/report.js.map +1 -1
- package/dist/commands/corpus/runner.d.ts +16 -1
- package/dist/commands/corpus/runner.d.ts.map +1 -1
- package/dist/commands/corpus/runner.js +40 -22
- package/dist/commands/corpus/runner.js.map +1 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/inventory.d.ts +2 -0
- package/dist/commands/inventory.d.ts.map +1 -1
- package/dist/commands/inventory.js +45 -4
- package/dist/commands/inventory.js.map +1 -1
- package/dist/commands/phase-utils.d.ts +183 -14
- package/dist/commands/phase-utils.d.ts.map +1 -1
- package/dist/commands/phase-utils.js +294 -29
- package/dist/commands/phase-utils.js.map +1 -1
- package/dist/commands/positional-args.d.ts +36 -0
- package/dist/commands/positional-args.d.ts.map +1 -0
- package/dist/commands/positional-args.js +52 -0
- package/dist/commands/positional-args.js.map +1 -0
- package/dist/commands/rag/query-command.d.ts +40 -0
- package/dist/commands/rag/query-command.d.ts.map +1 -1
- package/dist/commands/rag/query-command.js +47 -20
- package/dist/commands/rag/query-command.js.map +1 -1
- package/dist/commands/resources/index.d.ts.map +1 -1
- package/dist/commands/resources/index.js +27 -9
- package/dist/commands/resources/index.js.map +1 -1
- package/dist/commands/resources/scan.d.ts +37 -0
- package/dist/commands/resources/scan.d.ts.map +1 -1
- package/dist/commands/resources/scan.js +47 -32
- package/dist/commands/resources/scan.js.map +1 -1
- package/dist/commands/resources/validate.d.ts +136 -1
- package/dist/commands/resources/validate.d.ts.map +1 -1
- package/dist/commands/resources/validate.js +105 -66
- package/dist/commands/resources/validate.js.map +1 -1
- package/dist/commands/skill/review-checklist.d.ts.map +1 -1
- package/dist/commands/skill/review-checklist.js +5 -0
- package/dist/commands/skill/review-checklist.js.map +1 -1
- package/dist/commands/skill/review.d.ts.map +1 -1
- package/dist/commands/skill/review.js +7 -3
- package/dist/commands/skill/review.js.map +1 -1
- package/dist/commands/skill/test/run.d.ts +1 -1
- package/dist/commands/skill/test/run.d.ts.map +1 -1
- package/dist/commands/skill/test/run.js +8 -6
- package/dist/commands/skill/test/run.js.map +1 -1
- package/dist/commands/skills/build.d.ts +349 -11
- package/dist/commands/skills/build.d.ts.map +1 -1
- package/dist/commands/skills/build.js +901 -97
- package/dist/commands/skills/build.js.map +1 -1
- package/dist/commands/skills/command-helpers.d.ts +6 -10
- package/dist/commands/skills/command-helpers.d.ts.map +1 -1
- package/dist/commands/skills/command-helpers.js +6 -22
- package/dist/commands/skills/command-helpers.js.map +1 -1
- package/dist/commands/skills/list.d.ts +18 -0
- package/dist/commands/skills/list.d.ts.map +1 -1
- package/dist/commands/skills/list.js +41 -15
- package/dist/commands/skills/list.js.map +1 -1
- package/dist/commands/skills/package.d.ts +15 -0
- package/dist/commands/skills/package.d.ts.map +1 -1
- package/dist/commands/skills/package.js +30 -20
- package/dist/commands/skills/package.js.map +1 -1
- package/dist/commands/skills/scope-guard.d.ts +64 -0
- package/dist/commands/skills/scope-guard.d.ts.map +1 -0
- package/dist/commands/skills/scope-guard.js +86 -0
- package/dist/commands/skills/scope-guard.js.map +1 -0
- package/dist/commands/skills/validate-command.d.ts.map +1 -1
- package/dist/commands/skills/validate-command.js +41 -7
- package/dist/commands/skills/validate-command.js.map +1 -1
- package/dist/commands/skills/validate.d.ts +44 -3
- package/dist/commands/skills/validate.d.ts.map +1 -1
- package/dist/commands/skills/validate.js +217 -53
- package/dist/commands/skills/validate.js.map +1 -1
- package/dist/commands/validate.d.ts +18 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +89 -68
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.d.ts +113 -6
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +440 -123
- package/dist/commands/verify.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/skill-resolution/index.d.ts +1 -1
- package/dist/skill-resolution/index.d.ts.map +1 -1
- package/dist/skill-resolution/index.js +1 -1
- package/dist/skill-resolution/index.js.map +1 -1
- package/dist/skill-resolution/packaging-config.d.ts +15 -2
- package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
- package/dist/skill-resolution/packaging-config.js +39 -2
- package/dist/skill-resolution/packaging-config.js.map +1 -1
- package/dist/utils/command-error.d.ts +34 -1
- package/dist/utils/command-error.d.ts.map +1 -1
- package/dist/utils/command-error.js +32 -1
- package/dist/utils/command-error.js.map +1 -1
- package/dist/utils/help-loader.d.ts +6 -2
- package/dist/utils/help-loader.d.ts.map +1 -1
- package/dist/utils/help-loader.js +9 -16
- package/dist/utils/help-loader.js.map +1 -1
- package/dist/utils/issue-rendering.d.ts +130 -0
- package/dist/utils/issue-rendering.d.ts.map +1 -1
- package/dist/utils/issue-rendering.js +147 -0
- package/dist/utils/issue-rendering.js.map +1 -1
- package/dist/utils/issue-severity.d.ts +51 -0
- package/dist/utils/issue-severity.d.ts.map +1 -0
- package/dist/utils/issue-severity.js +63 -0
- package/dist/utils/issue-severity.js.map +1 -0
- package/dist/utils/output.d.ts +54 -5
- package/dist/utils/output.d.ts.map +1 -1
- package/dist/utils/output.js +111 -17
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/relativize-paths.d.ts +45 -0
- package/dist/utils/relativize-paths.d.ts.map +1 -0
- package/dist/utils/relativize-paths.js +67 -0
- package/dist/utils/relativize-paths.js.map +1 -0
- package/dist/utils/resource-loader.d.ts +21 -2
- package/dist/utils/resource-loader.d.ts.map +1 -1
- package/dist/utils/resource-loader.js +85 -11
- package/dist/utils/resource-loader.js.map +1 -1
- package/dist/utils/skill-packaging-config.d.ts +22 -1
- package/dist/utils/skill-packaging-config.d.ts.map +1 -1
- package/dist/utils/skill-packaging-config.js +26 -0
- package/dist/utils/skill-packaging-config.js.map +1 -1
- package/dist/utils/verdict-helpers.d.ts +4 -3
- package/dist/utils/verdict-helpers.d.ts.map +1 -1
- package/dist/utils/verdict-helpers.js +6 -13
- package/dist/utils/verdict-helpers.js.map +1 -1
- package/dist/version.d.ts +22 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +31 -2
- package/dist/version.js.map +1 -1
- package/docs/index.md +12 -0
- package/docs/rag.md +9 -4
- package/docs/resources.md +30 -14
- package/docs/skills.md +25 -7
- package/package.json +12 -12
|
@@ -7,20 +7,34 @@
|
|
|
7
7
|
* validates, and packages into dist/skills/<name>/.
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
|
-
import { rm } from 'node:fs/promises';
|
|
11
|
-
import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/agent-schema';
|
|
10
|
+
import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
|
|
11
|
+
import { allowUnusedIssues, calculateValidationStatus, countBySeverity, createAllowUsageLedger, } from '@vibe-agent-toolkit/agent-schema';
|
|
12
12
|
import { packageSkills, packagingConfigToPackageOptions, skillNameToFsPath, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
|
|
13
|
-
import { safePath } from '@vibe-agent-toolkit/utils';
|
|
13
|
+
import { safePath, toForwardSlash } from '@vibe-agent-toolkit/utils';
|
|
14
14
|
import { Command } from 'commander';
|
|
15
15
|
import * as yaml from 'yaml';
|
|
16
16
|
import { handleCommandError } from '../../utils/command-error.js';
|
|
17
17
|
import { loadConfig } from '../../utils/config-loader.js';
|
|
18
|
-
import { collectPostBuildIssues, formatIssueLines, formatIssueSetHeading, sumSeverityCounts, } from '../../utils/issue-rendering.js';
|
|
18
|
+
import { collectPostBuildIssues, countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatIssueSetHeading, formatPackagedFileCount, formatRunIssueLines, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../../utils/issue-rendering.js';
|
|
19
19
|
import { requireProjectRoot } from '../../utils/project-root-policy.js';
|
|
20
|
-
import { mergeSkillPackagingConfig } from '../../utils/skill-packaging-config.js';
|
|
20
|
+
import { collectDeclaredEvalSuites, mergeSkillPackagingConfig } from '../../utils/skill-packaging-config.js';
|
|
21
21
|
import { applyConfigVerdicts } from '../../utils/verdict-helpers.js';
|
|
22
22
|
import { filterSkillsByName, setupCommandContext, writeYamlHeader, } from './command-helpers.js';
|
|
23
|
+
import { rejectUnscopablePath } from './scope-guard.js';
|
|
23
24
|
import { discoverSkillsFromConfig } from './skill-discovery.js';
|
|
25
|
+
/**
|
|
26
|
+
* What a mis-scoped `vat skills build` used to do.
|
|
27
|
+
*
|
|
28
|
+
* The twin of the `vat skills validate` hole, and strictly the worse of the two:
|
|
29
|
+
* a mistyped path printed "No skills configuration found — nothing to build" and
|
|
30
|
+
* exited **0**, so a release pipeline whose build step named the wrong directory
|
|
31
|
+
* published having built nothing, with a green tick. Measured on a project whose
|
|
32
|
+
* bare `vat skills build` finds a skill: `vat skills build nope` exited 0.
|
|
33
|
+
*/
|
|
34
|
+
const SCOPE_SUBJECT = {
|
|
35
|
+
command: 'vat skills build',
|
|
36
|
+
silentSuccess: 'nothing to build',
|
|
37
|
+
};
|
|
24
38
|
export function createBuildCommand() {
|
|
25
39
|
const command = new Command('build');
|
|
26
40
|
command
|
|
@@ -28,8 +42,12 @@ export function createBuildCommand() {
|
|
|
28
42
|
.argument('[path]', 'Path to project directory (default: current directory)')
|
|
29
43
|
.option('--skill <name>', 'Build specific skill only')
|
|
30
44
|
.option('--dry-run', 'Preview build without creating files')
|
|
45
|
+
.option('-v, --verbose', 'Show every individual finding, not just the errors')
|
|
31
46
|
.option('--debug', 'Enable debug logging')
|
|
32
|
-
.action(
|
|
47
|
+
.action(async (pathArg, options) => {
|
|
48
|
+
rejectUnscopablePath(SCOPE_SUBJECT, pathArg);
|
|
49
|
+
await buildCommand(pathArg, options);
|
|
50
|
+
})
|
|
33
51
|
.addHelpText('after', `
|
|
34
52
|
Description:
|
|
35
53
|
Discovers SKILL.md files using include/exclude globs from the skills
|
|
@@ -67,10 +85,87 @@ Output:
|
|
|
67
85
|
YAML summary -> stdout (for programmatic parsing)
|
|
68
86
|
Build progress -> stderr (for human reading)
|
|
69
87
|
|
|
88
|
+
On stderr, every findings heading names its skill, the whole set and its
|
|
89
|
+
severity breakdown, and errors are always printed in full beneath it.
|
|
90
|
+
Warnings and info findings stay collapsed into that heading unless
|
|
91
|
+
--verbose, because they are the high-cardinality ones (one adopter skill
|
|
92
|
+
carries 348 LINK_DROPPED_BY_DEPTH warnings alone); one line at the end of
|
|
93
|
+
the run names how many were collapsed.
|
|
94
|
+
|
|
95
|
+
--verbose changes the stderr report ONLY. The stdout YAML is identical at
|
|
96
|
+
either verbosity: every row carries its full issues array (code, message,
|
|
97
|
+
location, fix) whether or not the human report printed it, because filtering
|
|
98
|
+
a machine-readable document by a human report's verbosity breaks the
|
|
99
|
+
consumers that parse it.
|
|
100
|
+
|
|
101
|
+
skills: one row per skill whose bundle EXISTS on disk — empty
|
|
102
|
+
whenever outputCommitted is false
|
|
103
|
+
skillsStaged: the same rows for a run that built its bundles and then
|
|
104
|
+
aborted the promotion. Deliberately a different key, and
|
|
105
|
+
deliberately carrying no outputPath: nothing was written, so
|
|
106
|
+
there is no path to publish. A consumer that installs,
|
|
107
|
+
symlinks or checksums what a build produced reads skills[]
|
|
108
|
+
and correctly finds nothing.
|
|
109
|
+
failedSkills: one row per skill that could not be packaged AT ALL (no
|
|
110
|
+
bundle exists for it); each carries name, error and an
|
|
111
|
+
issueCounts of one error
|
|
112
|
+
validationFailedSkills / skillsFailedValidation:
|
|
113
|
+
the THIRD failure mode — skills rejected by the PRE-build
|
|
114
|
+
source validation, so packaging was never attempted for
|
|
115
|
+
them. Distinct from failedSkills (packaging was attempted
|
|
116
|
+
and threw) because the fix is different: these carry their
|
|
117
|
+
own issueCounts, and the findings behind them are on stderr.
|
|
118
|
+
skillsWithErrors / skillsWithErrorNames:
|
|
119
|
+
the FOURTH failure mode — skills that packaged fine and then
|
|
120
|
+
emitted post-build validation errors. They are counted in
|
|
121
|
+
skillsBuilt, not skillsFailed, so read ALL of these before
|
|
122
|
+
concluding a run was clean: the exit code follows this one too.
|
|
123
|
+
runIssueCounts: findings that belong to the run rather than to any one
|
|
124
|
+
skill (ALLOW_UNUSED)
|
|
125
|
+
issueCounts: the run total, which reconciles against the rows above:
|
|
126
|
+
|
|
127
|
+
issueCounts = sum(skills[].issueCounts)
|
|
128
|
+
+ sum(skillsStaged[].issueCounts)
|
|
129
|
+
+ sum(failedSkills[].issueCounts)
|
|
130
|
+
+ sum(validationFailedSkills[].issueCounts)
|
|
131
|
+
+ runIssueCounts
|
|
132
|
+
|
|
133
|
+
Every error, warning and info in the header total is
|
|
134
|
+
therefore attributable to a row you can point at. (Exactly
|
|
135
|
+
one of skills / skillsStaged is ever non-empty, so no
|
|
136
|
+
packaged bundle is counted twice.)
|
|
137
|
+
outputCommitted:
|
|
138
|
+
whether dist/skills was REPLACED by this run. A build writes
|
|
139
|
+
into a staging directory and promotes it only if the whole
|
|
140
|
+
run is clean, so 'false' means nothing on disk changed and
|
|
141
|
+
the previous dist/skills (if any) is exactly as it was — and
|
|
142
|
+
the packaged rows are published as skillsStaged, without an
|
|
143
|
+
outputPath, because none was written.
|
|
144
|
+
promotionError: present ONLY when the promotion/discard step itself failed
|
|
145
|
+
(EACCES, ENOSPC, or the ENOTEMPTY a concurrent build in the
|
|
146
|
+
same dist/ produces). Its text names what is on disk and the
|
|
147
|
+
single 'mv' that recovers it, because the parked path carries
|
|
148
|
+
a random suffix nobody can reconstruct. Read outputCommitted
|
|
149
|
+
beside it: a promotion that landed the tree and then failed to
|
|
150
|
+
clean up still committed the output.
|
|
151
|
+
|
|
70
152
|
Exit Codes:
|
|
71
153
|
0 - All skills built successfully (or dry-run preview)
|
|
72
|
-
1 - One or more skills
|
|
73
|
-
|
|
154
|
+
1 - One or more skills failed pre-build validation, emitted post-build
|
|
155
|
+
validation errors, or could not be packaged at all. No single skill
|
|
156
|
+
aborts the run: every failure of every kind is collected and reported
|
|
157
|
+
in ONE pass, so one build cycle surfaces all the work. Because the run
|
|
158
|
+
failed, outputCommitted is false and dist/skills was left untouched.
|
|
159
|
+
2 - System error (config invalid, directory not found), a [path] argument
|
|
160
|
+
this command cannot read a config from, or a failure on the promotion
|
|
161
|
+
path — see promotionError. On a promotion failure the YAML document is
|
|
162
|
+
still written; it is the only report that says where the output went.
|
|
163
|
+
|
|
164
|
+
A [path] naming a directory that does not exist, is not a directory, or holds
|
|
165
|
+
no vibe-agent-toolkit.config.yaml is REFUSED with exit 2. It previously
|
|
166
|
+
printed "No skills configuration found — nothing to build" and exited 0, so a
|
|
167
|
+
pipeline whose build step named the wrong directory shipped having built
|
|
168
|
+
nothing, and reported success.
|
|
74
169
|
|
|
75
170
|
Requirements:
|
|
76
171
|
projectRoot: required (errors if no vibe-agent-toolkit.config.yaml or .git/ ancestor)
|
|
@@ -87,52 +182,85 @@ Example:
|
|
|
87
182
|
* Render every emitted pre-build finding, each labelled with its own severity.
|
|
88
183
|
*
|
|
89
184
|
* `allErrors` is the full emitted set INCLUDING info (its name lies — see the
|
|
90
|
-
* doc comment on `PackagingValidationResult`). Walking
|
|
91
|
-
*
|
|
185
|
+
* doc comment on `PackagingValidationResult`). Walking only the errors plus the
|
|
186
|
+
* `ALLOW_EXPIRED` subset of the warnings, which is what this used to do,
|
|
92
187
|
* dropped every other warning and every info finding from a report that had
|
|
93
188
|
* already decided to abort the build.
|
|
94
189
|
*
|
|
190
|
+
* `verbose` picks how much of that set is spelled out, never how much of it is
|
|
191
|
+
* COUNTED: the heading is built from `allErrors` either way, so the severity
|
|
192
|
+
* breakdown stays whole even when the bodies below it do not (see
|
|
193
|
+
* `issuesToRenderAtVerbosity`). Every error is printed at both verbosities —
|
|
194
|
+
* these are the findings that just aborted the build, so putting one behind a
|
|
195
|
+
* flag would mean re-running to learn what broke.
|
|
196
|
+
*
|
|
95
197
|
* Pure so the whole set is assertable, not a chosen subset of it.
|
|
96
198
|
*/
|
|
97
|
-
export function formatPreBuildIssueReport(validationResult) {
|
|
199
|
+
export function formatPreBuildIssueReport(validationResult, verbose) {
|
|
98
200
|
const lines = [];
|
|
99
201
|
if (validationResult.allErrors.length > 0) {
|
|
100
202
|
lines.push(`\n ${formatIssueSetHeading(validationResult.allErrors)}:`);
|
|
101
|
-
for (const issue of validationResult.allErrors) {
|
|
203
|
+
for (const issue of issuesToRenderAtVerbosity(validationResult.allErrors, verbose)) {
|
|
102
204
|
lines.push(...formatIssueLines(issue, ' '));
|
|
103
205
|
}
|
|
104
206
|
}
|
|
105
207
|
return lines;
|
|
106
208
|
}
|
|
107
209
|
/**
|
|
108
|
-
* Render post-build integrity issues, each prefixed by its OWN
|
|
210
|
+
* Render ONE skill's post-build integrity issues, each prefixed by its OWN
|
|
211
|
+
* resolved severity.
|
|
109
212
|
*
|
|
110
213
|
* Reads BOTH post-build channels (see `collectPostBuildIssues`) so a build that
|
|
111
214
|
* failed purely on the built-output validation still shows the findings that
|
|
112
215
|
* failed it, and the heading names the set's actual severity mix rather than
|
|
113
216
|
* calling every set by its worst member.
|
|
114
217
|
*
|
|
218
|
+
* The heading NAMES THE SKILL, matching `vat claude plugin build`'s per-skill
|
|
219
|
+
* heading, because this line is printed in the outcome pass — the validation pass
|
|
220
|
+
* has already emitted every `Building skill: <name>` banner, so at scale (92
|
|
221
|
+
* banners, then 86 outcome blocks) an unnamed heading sits under an unrelated
|
|
222
|
+
* skill's banner and is read as that skill's findings.
|
|
223
|
+
*
|
|
224
|
+
* The heading is unconditional and counts the WHOLE set; `verbose` decides only
|
|
225
|
+
* which findings get a block beneath it (see `issuesToRenderAtVerbosity`). This
|
|
226
|
+
* loop printing every occurrence is what made a 90-skill build emit 6,552 stderr
|
|
227
|
+
* lines against the 804 `vat skills validate` emitted for the same corpus — 1,620
|
|
228
|
+
* of them one high-cardinality warning code. A set whose findings all collapse
|
|
229
|
+
* still renders its heading: collapsing that too would turn a warning-carrying
|
|
230
|
+
* build into silence, which is the reassuring direction this module warns about.
|
|
231
|
+
*
|
|
232
|
+
* The trailing colon is what varies: it introduces the blocks below, so a heading
|
|
233
|
+
* with nothing beneath it does not print one. A colon promising a list that the
|
|
234
|
+
* current verbosity will not print is the defect this half fixes; the other half
|
|
235
|
+
* is the run-level `--verbose` hint (see `formatCollapsedFindingsHint`).
|
|
236
|
+
*
|
|
115
237
|
* Pure: returns the lines instead of writing them, so the whole rendered set is
|
|
116
238
|
* assertable without capturing a stream.
|
|
117
239
|
*/
|
|
118
|
-
export function formatPostBuildIssueReport(result) {
|
|
240
|
+
export function formatPostBuildIssueReport(skillName, result, verbose) {
|
|
119
241
|
const issues = collectPostBuildIssues(result);
|
|
120
242
|
if (issues.length === 0)
|
|
121
243
|
return [];
|
|
122
|
-
const
|
|
123
|
-
|
|
244
|
+
const rendered = issuesToRenderAtVerbosity(issues, verbose);
|
|
245
|
+
const heading = ` ${skillName}: ${formatIssueSetHeading(issues, 'post-build')}`;
|
|
246
|
+
const lines = [rendered.length === 0 ? heading : `${heading}:`];
|
|
247
|
+
for (const issue of rendered) {
|
|
124
248
|
lines.push(...formatIssueLines(issue, ' '));
|
|
125
249
|
}
|
|
126
250
|
return lines;
|
|
127
251
|
}
|
|
128
252
|
/**
|
|
129
|
-
* Log post-build integrity issues to stderr (the human stream;
|
|
130
|
-
* reserved for the YAML summary).
|
|
253
|
+
* Log one skill's post-build integrity issues to stderr (the human stream;
|
|
254
|
+
* stdout is reserved for the YAML summary).
|
|
255
|
+
*
|
|
256
|
+
* Returns how many findings this verbosity collapsed, so the run can print ONE
|
|
257
|
+
* hint naming the total rather than one per skill.
|
|
131
258
|
*/
|
|
132
|
-
function logPostBuildIssues(result, logger) {
|
|
133
|
-
for (const line of formatPostBuildIssueReport(result)) {
|
|
259
|
+
function logPostBuildIssues(skillName, result, logger, verbose) {
|
|
260
|
+
for (const line of formatPostBuildIssueReport(skillName, result, verbose)) {
|
|
134
261
|
logger.info(line);
|
|
135
262
|
}
|
|
263
|
+
return countCollapsedFindings(collectPostBuildIssues(result), verbose);
|
|
136
264
|
}
|
|
137
265
|
/**
|
|
138
266
|
* Display allowed issues for context
|
|
@@ -146,27 +274,48 @@ function displayIgnoredErrors(validationResult, logger) {
|
|
|
146
274
|
}
|
|
147
275
|
}
|
|
148
276
|
/**
|
|
149
|
-
*
|
|
277
|
+
* Run the PRE-build source validation for one skill and REPORT the verdict —
|
|
278
|
+
* never act on it.
|
|
279
|
+
*
|
|
280
|
+
* This used to `process.exit(1)` from inside the per-skill loop, so a run named
|
|
281
|
+
* the first bad skill and nothing else. Measured on a 90-skill adopter: 3 of the
|
|
282
|
+
* 28 errors and 1 of the 6 broken skills, which is six full build cycles to
|
|
283
|
+
* discover the work. Returning the failure lets {@link runSkillBuild} collect
|
|
284
|
+
* every one of them and fail once, at the end, with the whole list.
|
|
285
|
+
*
|
|
286
|
+
* The returned counts come from `allErrors` — the full EMITTED set including
|
|
287
|
+
* warnings and info (its name lies; see `PackagingValidationResult`) — so the
|
|
288
|
+
* row this becomes carries the same three-valued distribution every other row
|
|
289
|
+
* publishes, rather than a flat "one error" stand-in.
|
|
150
290
|
*/
|
|
151
|
-
async function
|
|
291
|
+
async function validateSkillBeforeBuild(input) {
|
|
292
|
+
const { skillName, sourcePath, packagingConfig, logger, locationRoot, allowLedger, projectSkills, verbose } = input;
|
|
152
293
|
logger.debug(` Validating skill: ${skillName}`);
|
|
153
|
-
|
|
294
|
+
// The run's ledger, not this call's: an allow entry scoped to a SOURCE
|
|
295
|
+
// filename can only ever match here — packaging renames the file to
|
|
296
|
+
// `SKILL.md` and the built lane drops the source-only codes — so a build that
|
|
297
|
+
// withholds this lane's matches from the run reports live entries as dead.
|
|
298
|
+
//
|
|
299
|
+
// The run's declared eval suites likewise: this lane must model the same bundle
|
|
300
|
+
// the packager below produces, and that bundle excludes EVERY skill's test input.
|
|
301
|
+
const validationResult = await validateSkillForPackaging(sourcePath, packagingConfig, 'source', { allowLedger, projectSkills });
|
|
154
302
|
applyConfigVerdicts(validationResult, packagingConfig.targets, sourcePath, locationRoot);
|
|
155
303
|
if (validationResult.status !== 'error') {
|
|
156
304
|
if (validationResult.ignoredErrors.length > 0) {
|
|
157
305
|
logger.debug(` ${validationResult.ignoredErrors.length} issue(s) allowed by config`);
|
|
158
306
|
}
|
|
159
|
-
return;
|
|
307
|
+
return undefined;
|
|
160
308
|
}
|
|
161
|
-
// Validation failed
|
|
309
|
+
// Validation failed — display every emitted finding, then hand the verdict
|
|
310
|
+
// back. No `Build aborted` line: the run continues, and saying otherwise here
|
|
311
|
+
// would contradict the 89 skills that go on to build beneath this message.
|
|
162
312
|
logger.error(`\nSkill validation failed: ${skillName}`);
|
|
163
313
|
logger.error(` Source: ${sourcePath}`);
|
|
164
|
-
for (const line of formatPreBuildIssueReport(validationResult)) {
|
|
314
|
+
for (const line of formatPreBuildIssueReport(validationResult, verbose)) {
|
|
165
315
|
logger.error(line);
|
|
166
316
|
}
|
|
167
317
|
displayIgnoredErrors(validationResult, logger);
|
|
168
|
-
|
|
169
|
-
process.exit(1);
|
|
318
|
+
return { name: skillName, issueCounts: countBySeverity(validationResult.allErrors) };
|
|
170
319
|
}
|
|
171
320
|
/**
|
|
172
321
|
* Output dry-run results
|
|
@@ -206,6 +355,14 @@ function performDryRun(skillsToBuild, duration, logger) {
|
|
|
206
355
|
logger.info(`\nDry-run complete (no files created)`);
|
|
207
356
|
logger.info(` Run without --dry-run to build the skills`);
|
|
208
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* What ONE unpackageable skill contributes to the run's counts.
|
|
360
|
+
*
|
|
361
|
+
* A fresh object per row: these are published as separate YAML nodes, and a
|
|
362
|
+
* shared reference is how a document grows anchors (or a later mutation edits
|
|
363
|
+
* every row at once).
|
|
364
|
+
*/
|
|
365
|
+
const failureIssueCounts = () => ({ errors: 1, warnings: 0, info: 0 });
|
|
209
366
|
/**
|
|
210
367
|
* The archived summary of a build, as YAML fields.
|
|
211
368
|
*
|
|
@@ -215,12 +372,66 @@ function performDryRun(skillsToBuild, duration, logger) {
|
|
|
215
372
|
* from the same issues the human stream renders, and the per-severity counts
|
|
216
373
|
* ride beside it because a status cannot express a three-valued distribution:
|
|
217
374
|
* `success` here means "nothing you must act on", not "there was nothing to see".
|
|
375
|
+
*
|
|
376
|
+
* The header total reconciles against the document, by construction:
|
|
377
|
+
*
|
|
378
|
+
* issueCounts === Σ (skills[] ∪ skillsStaged[])[].issueCounts
|
|
379
|
+
* + Σ failedSkills[].issueCounts
|
|
380
|
+
* + Σ validationFailedSkills[].issueCounts
|
|
381
|
+
* + runIssueCounts
|
|
382
|
+
*
|
|
383
|
+
* `skills` and `skillsStaged` are the same population under two names, and
|
|
384
|
+
* exactly one of them is ever non-empty (see the comment on `skills` below), so
|
|
385
|
+
* summing both is summing each packaged bundle once.
|
|
386
|
+
*
|
|
387
|
+
* Every addend therefore has a ROW a reader can point at. Counting the failures
|
|
388
|
+
* only in the header — which is how this first shipped — reproduced, one command
|
|
389
|
+
* over, the exact defect `vat skills validate` was fixed for: a header reporting
|
|
390
|
+
* more than its rows summed to (there, 1814 warnings against 1800), leaving a
|
|
391
|
+
* consumer to hand-count a list to find out what the difference was. `--help`
|
|
392
|
+
* states this identity; changing it means changing that text too.
|
|
393
|
+
*
|
|
394
|
+
* Each packaged row carries its `issues` as well as its counts. It did not, for
|
|
395
|
+
* a while: the findings were collected here and then dropped at the publish
|
|
396
|
+
* step, so the document offered a count with no `code`, no location and no fix
|
|
397
|
+
* string at ANY verbosity — an adopter run published 67 warnings and zero
|
|
398
|
+
* findings. That is acute for the four detectors this lane added: their output
|
|
399
|
+
* existed only on stderr, where no CI consumer reads it. Full findings on the
|
|
400
|
+
* rows is the shape `vat audit` and `vat skills validate --verbose` already
|
|
401
|
+
* publish, and it is verbosity-INDEPENDENT here for the reason stated in
|
|
402
|
+
* `issuesToRenderAtVerbosity`: filtering a machine-readable document by a human
|
|
403
|
+
* report's verbosity silently breaks the consumers that parse it.
|
|
404
|
+
*
|
|
405
|
+
* Takes the whole {@link SkillBuildRun} rather than a positional list of its
|
|
406
|
+
* parts: the run has four populations now, and a positional call site can
|
|
407
|
+
* transpose two same-shaped lists in silence.
|
|
218
408
|
*/
|
|
219
|
-
export function buildYamlSummary(
|
|
409
|
+
export function buildYamlSummary(run, duration) {
|
|
410
|
+
const { results, failures, runIssues, skillsWithErrors, validationFailures, outputCommitted } = run;
|
|
220
411
|
const perSkill = results.map(({ name, result }) => {
|
|
221
412
|
const issues = collectPostBuildIssues(result);
|
|
222
413
|
return {
|
|
223
414
|
name,
|
|
415
|
+
// KNOWN, DELIBERATELY NOT FIXED — this is an ABSOLUTE path, so stdout carries
|
|
416
|
+
// `$HOME`. Confirmed on a real 90-skill adopter run:
|
|
417
|
+
// `outputPath: /Users/<user>/Workspaces/.../dist/skills/<name>`. It is absolute
|
|
418
|
+
// because `runSkillBuild` builds it with `safePath.resolve(cwd, 'dist', ...)`.
|
|
419
|
+
//
|
|
420
|
+
// Do NOT "helpfully" relativize it. It is blocked on an approved-but-unbuilt
|
|
421
|
+
// design decision that is the project owner's call: the document must state one
|
|
422
|
+
// `root:` and re-base every path onto it, the same coordinate-system rule
|
|
423
|
+
// `vat audit` already follows (see `deriveScanRoot` in ../audit.ts — "the ONE base
|
|
424
|
+
// every `path` and `location` in a report is expressed relative to"). Making this
|
|
425
|
+
// one field relative ahead of that decision picks the anchor by accident and
|
|
426
|
+
// leaves the document in two coordinate systems.
|
|
427
|
+
//
|
|
428
|
+
// Do not read the rest of the report as clean because the failure MESSAGES
|
|
429
|
+
// are: those were scrubbed of absolute project paths and are confirmed clean,
|
|
430
|
+
// but the SUCCESS-path fields were not. This one and the plugin lane's `dir:`
|
|
431
|
+
// (5 places in the measured report — see `pluginBuildCommand` in
|
|
432
|
+
// ../claude/plugin/build.ts) are the two that still publish `$HOME` into CI
|
|
433
|
+
// logs, and they wait on the same `root:` decision — fix them as a pair, or
|
|
434
|
+
// the two lanes end up anchored differently.
|
|
224
435
|
outputPath: result.outputPath,
|
|
225
436
|
filesPackaged: result.files.dependencies.length + 1,
|
|
226
437
|
issueCounts: countBySeverity(issues),
|
|
@@ -228,46 +439,642 @@ export function buildYamlSummary(results, duration) {
|
|
|
228
439
|
};
|
|
229
440
|
});
|
|
230
441
|
const allIssues = perSkill.flatMap((s) => s.issues);
|
|
442
|
+
const runIssueCounts = countBySeverity(runIssues);
|
|
443
|
+
// A skill that THREW emits no issues at all — it never reached the lanes that
|
|
444
|
+
// produce them. Deriving the header purely from issue channels therefore said
|
|
445
|
+
// `success` for a run the command then exited 1 on, which is exactly the
|
|
446
|
+
// reassuring contradiction this summary exists to prevent. Each failure is
|
|
447
|
+
// counted as one error so `status`, `issueCounts` and the exit code agree —
|
|
448
|
+
// and that error is published ON the failure's own row (see
|
|
449
|
+
// `failureIssueCounts`), never as a header-only addend, so the identity
|
|
450
|
+
// documented in `--help` holds.
|
|
451
|
+
const failedSkills = failures.map(({ name, message }) => ({
|
|
452
|
+
name,
|
|
453
|
+
error: message,
|
|
454
|
+
issueCounts: failureIssueCounts(),
|
|
455
|
+
}));
|
|
456
|
+
// The THIRD population, and the one an adopter meets first: a skill the
|
|
457
|
+
// PRE-build source validation rejected, so packaging was never attempted. It
|
|
458
|
+
// gets its own rows rather than joining either list above, because neither
|
|
459
|
+
// answers the question this one raises — `failedSkills` says packaging was
|
|
460
|
+
// attempted and threw, `skills` says a bundle exists. And unlike a throw, this
|
|
461
|
+
// failure HAS a severity distribution of its own, so it publishes the real
|
|
462
|
+
// counts instead of the flat `failureIssueCounts()` stand-in.
|
|
463
|
+
//
|
|
464
|
+
// KNOWN, DELIBERATELY NOT FIXED — these rows carry no `issues` key, so the
|
|
465
|
+
// findings that just failed the build are COUNTED here and never NAMED. On a
|
|
466
|
+
// real adopter monorepo build, all 28 build-blocking errors were counted but
|
|
467
|
+
// never named — 551 of 1707 findings were named overall. A CI consumer reading
|
|
468
|
+
// stdout gets a number it cannot act on; the findings themselves exist only on
|
|
469
|
+
// stderr.
|
|
470
|
+
//
|
|
471
|
+
// Stated explicitly, because a reader will otherwise assume it is covered: the
|
|
472
|
+
// sibling defect on the `skills` rows — their `issues` array dropped at the
|
|
473
|
+
// publish seam, see the comment on `buildYamlSummary` — was fixed, and that fix
|
|
474
|
+
// does NOT cover this row type. This one is not a publish-seam drop: the
|
|
475
|
+
// findings never reach here at all. `SkillValidationFailure` is `{ name,
|
|
476
|
+
// issueCounts }`, so fixing it means widening that type back at the pre-build
|
|
477
|
+
// validation lane, not adding a key at this call.
|
|
478
|
+
const validationFailedSkills = validationFailures.map(({ name, issueCounts }) => ({
|
|
479
|
+
name,
|
|
480
|
+
issueCounts,
|
|
481
|
+
}));
|
|
231
482
|
return {
|
|
232
|
-
|
|
233
|
-
|
|
483
|
+
// `promotionError` gates too: a run whose bundles all validated cleanly and
|
|
484
|
+
// whose promotion then threw has no issue to derive a status from, so
|
|
485
|
+
// deriving it from the issue channels alone would publish `success` beside an
|
|
486
|
+
// exit code of 2 and a `dist/skills` nobody can vouch for.
|
|
487
|
+
status: failures.length > 0 || validationFailures.length > 0 || run.promotionError !== undefined
|
|
488
|
+
? 'error'
|
|
489
|
+
: calculateValidationStatus([...allIssues, ...runIssues]),
|
|
490
|
+
issueCounts: sumSeverityCounts([
|
|
491
|
+
...perSkill.map((s) => s.issueCounts),
|
|
492
|
+
...failedSkills.map((s) => s.issueCounts),
|
|
493
|
+
...validationFailedSkills.map((s) => s.issueCounts),
|
|
494
|
+
runIssueCounts,
|
|
495
|
+
]),
|
|
496
|
+
runIssueCounts,
|
|
234
497
|
skillsBuilt: results.length,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
498
|
+
skillsFailed: failures.length,
|
|
499
|
+
skillsFailedValidation: validationFailures.length,
|
|
500
|
+
// Whether dist/skills was actually REPLACED. Carried through from the run
|
|
501
|
+
// rather than re-derived here: two definitions of "did this build change
|
|
502
|
+
// anything" is exactly how a report ends up contradicting the disk.
|
|
503
|
+
outputCommitted,
|
|
504
|
+
...(run.promotionError === undefined ? {} : { promotionError: run.promotionError }),
|
|
505
|
+
// The OTHER meaning of "failed", published because the exit code follows
|
|
506
|
+
// THIS one and nothing in the document did. `skillsFailed` counts skills
|
|
507
|
+
// that could not be packaged at all; a skill that packaged fine and then
|
|
508
|
+
// emitted post-build validation errors is a `skillsBuilt`, so a real run
|
|
509
|
+
// printed "Build failed: 3 skill(s) emitted post-build validation errors"
|
|
510
|
+
// over `skillsFailed: 0` and `failedSkills: []`, and exited 1. A CI job
|
|
511
|
+
// reading either field saw a clean build. Both categories are now named.
|
|
512
|
+
skillsWithErrors: [...skillsWithErrors],
|
|
513
|
+
// `skills` lists what exists on disk. A failed skill is published in its own
|
|
514
|
+
// list rather than here, because every field of this shape (outputPath,
|
|
515
|
+
// filesPackaged) would have to be invented for a bundle that was not written.
|
|
516
|
+
//
|
|
517
|
+
// That invariant did not survive `outputCommitted: false`, which is the OTHER
|
|
518
|
+
// way a row can name a path nothing was written to: the bundles are built into
|
|
519
|
+
// staging and the promotion is then aborted, so a failed 86-skill run published
|
|
520
|
+
// 86 `dist/skills/<name>` paths of which 85 did not exist (verified with
|
|
521
|
+
// `fs.existsSync` over every row). A CI step reading `skills[].outputPath` to
|
|
522
|
+
// install, symlink or checksum got 85 dead paths, with no signal but a sibling
|
|
523
|
+
// boolean it was not told to read.
|
|
524
|
+
//
|
|
525
|
+
// So the rows MOVE when the swap did not happen, rather than losing a field:
|
|
526
|
+
// a consumer iterating `skills[]` sees the empty list its documented meaning
|
|
527
|
+
// ("what exists on disk") demands, while `skillsStaged[]` — a name that
|
|
528
|
+
// promises nothing about the disk — keeps the findings and the counts the
|
|
529
|
+
// header identity is summed from. Those rows publish no `outputPath` at all,
|
|
530
|
+
// because there is no honest value for it: the staging path has been deleted
|
|
531
|
+
// and the final path was never written.
|
|
532
|
+
skills: outputCommitted ? perSkill : [],
|
|
533
|
+
skillsStaged: outputCommitted
|
|
534
|
+
? []
|
|
535
|
+
: perSkill.map(({ name, filesPackaged, issueCounts, issues }) => ({
|
|
536
|
+
name,
|
|
537
|
+
filesPackaged,
|
|
538
|
+
issueCounts,
|
|
539
|
+
issues,
|
|
540
|
+
})),
|
|
541
|
+
failedSkills,
|
|
542
|
+
validationFailedSkills,
|
|
543
|
+
runIssues: [...runIssues],
|
|
241
544
|
duration: `${duration}ms`,
|
|
242
545
|
};
|
|
243
546
|
}
|
|
244
547
|
/**
|
|
245
548
|
* Output build results
|
|
246
549
|
*/
|
|
247
|
-
function outputBuildYaml(
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
550
|
+
function outputBuildYaml(run, duration) {
|
|
551
|
+
const summary = buildYamlSummary(run, duration);
|
|
552
|
+
const { status, skillsBuilt, skillsFailed, skillsFailedValidation, issueCounts, runIssueCounts, skills, skillsStaged, failedSkills, validationFailedSkills, outputCommitted, duration: durationText, } = summary;
|
|
553
|
+
// In the header, beside the other counts: these are the numbers the exit code
|
|
554
|
+
// actually follows, so a reader who stops at the header is not misled by it.
|
|
555
|
+
// `outputCommitted` rides up here too — a reader who sees exit 1 needs to know
|
|
556
|
+
// whether their dist/skills still exists before they do anything else.
|
|
557
|
+
writeYamlHeader({
|
|
558
|
+
status,
|
|
559
|
+
skillsBuilt,
|
|
560
|
+
skillsFailed,
|
|
561
|
+
skillsFailedValidation,
|
|
562
|
+
skillsWithErrors: summary.skillsWithErrors.length,
|
|
563
|
+
outputCommitted,
|
|
564
|
+
});
|
|
565
|
+
process.stdout.write(yaml.stringify({
|
|
566
|
+
issueCounts,
|
|
567
|
+
runIssueCounts,
|
|
568
|
+
// Published in the BODY, not the header: `writeYamlHeader` writes raw
|
|
569
|
+
// `key: value` lines, and this text is multi-line by design (what is on
|
|
570
|
+
// disk, and the `mv` that recovers it). Emitting it there would produce a
|
|
571
|
+
// document that does not parse — the one failure mode a report about a
|
|
572
|
+
// failed build must not add. `status: error` and `outputCommitted` are
|
|
573
|
+
// already in the header, so a reader who stops there is not misled.
|
|
574
|
+
...(summary.promotionError === undefined ? {} : { promotionError: summary.promotionError }),
|
|
575
|
+
skills,
|
|
576
|
+
// Always published, even empty, for the same reason `failedSkills` is: an
|
|
577
|
+
// absent key reads as "this run had no such concept", and a consumer that
|
|
578
|
+
// has to distinguish absent from empty will get it wrong.
|
|
579
|
+
skillsStaged,
|
|
580
|
+
failedSkills,
|
|
581
|
+
validationFailedSkills,
|
|
582
|
+
skillsWithErrorNames: summary.skillsWithErrors,
|
|
583
|
+
runIssues: summary.runIssues,
|
|
584
|
+
}, { indent: 2, lineWidth: 0, aliasDuplicateObjects: false }));
|
|
251
585
|
process.stdout.write(`duration: ${durationText}\n`);
|
|
252
586
|
}
|
|
587
|
+
/** How the human stream names the output tree. One spelling, one place. */
|
|
588
|
+
const DIST_SKILLS_LABEL = 'dist/skills';
|
|
589
|
+
/** The tree a successful build promotes its staged bundles into. */
|
|
590
|
+
function distSkillsDir(cwd) {
|
|
591
|
+
return safePath.resolve(cwd, 'dist', 'skills');
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Where a skill's bundle lives once a build has earned the swap.
|
|
595
|
+
*
|
|
596
|
+
* THE one definition, shared by the progress line, the published `outputPath`
|
|
597
|
+
* and the staging promotion — so the path a reader is told about is by
|
|
598
|
+
* construction the path the swap lands on.
|
|
599
|
+
*/
|
|
600
|
+
function finalOutputPath(cwd, skillName) {
|
|
601
|
+
return safePath.join(distSkillsDir(cwd), skillNameToFsPath(skillName));
|
|
602
|
+
}
|
|
253
603
|
/**
|
|
254
|
-
*
|
|
255
|
-
*
|
|
604
|
+
* Rewrite `value` when it names `from` or something inside it; otherwise
|
|
605
|
+
* `undefined`, so a caller can fall through to the next candidate base.
|
|
606
|
+
*
|
|
607
|
+
* The separator in the prefix test is load-bearing: `beginStagedBuild` parks the
|
|
608
|
+
* previous output at `${root}.previous`, a SIBLING whose string starts with the
|
|
609
|
+
* staging root. A bare `startsWith` would rewrite it into the final tree and
|
|
610
|
+
* report a finding against a path that never held the file.
|
|
256
611
|
*/
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
612
|
+
function replacePathPrefix(value, from, to) {
|
|
613
|
+
if (value === from)
|
|
614
|
+
return to;
|
|
615
|
+
return value.startsWith(`${from}/`) ? `${to}${value.slice(from.length)}` : undefined;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Map any path anchored on the run's staging root onto the tree the swap lands
|
|
619
|
+
* on — the ONE re-anchoring, applied to every path a result publishes.
|
|
620
|
+
*
|
|
621
|
+
* Staging is transient in BOTH outcomes: `dist/.vat-skills-<rand>` is renamed
|
|
622
|
+
* away on success and deleted on failure, and the `mkdtemp` suffix means a
|
|
623
|
+
* reader cannot even reconstruct it. Any path that escapes this mapping is
|
|
624
|
+
* therefore unopenable by the time anyone reads it — which is what the published
|
|
625
|
+
* `outputPath` was fixed for, while the per-finding `location` strings kept
|
|
626
|
+
* leaking it (`Location: dist/.vat-skills-uxxJfu/demo/SKILL.md`, observed on a
|
|
627
|
+
* real adopter and on a two-skill fixture). One mapper, applied at one seam, is
|
|
628
|
+
* what keeps the two from drifting apart again.
|
|
629
|
+
*
|
|
630
|
+
* Both spellings are handled because the two carriers use different coordinate
|
|
631
|
+
* systems: `outputPath` is absolute (see `buildYamlSummary`), while a finding's
|
|
632
|
+
* `location` is relative to the project root the validator anchored on. The
|
|
633
|
+
* mapping preserves whichever it was given — re-basing a location here would put
|
|
634
|
+
* the report into two coordinate systems, the very thing `locationRoot` exists
|
|
635
|
+
* to prevent.
|
|
636
|
+
*/
|
|
637
|
+
function createStagingPathMapper(cwd, stagingRoot) {
|
|
638
|
+
const absoluteFrom = toForwardSlash(stagingRoot);
|
|
639
|
+
const absoluteTo = distSkillsDir(cwd);
|
|
640
|
+
const relativeFrom = toForwardSlash(safePath.relative(cwd, stagingRoot));
|
|
641
|
+
const relativeTo = toForwardSlash(safePath.relative(cwd, absoluteTo));
|
|
642
|
+
return (value) => {
|
|
643
|
+
const forward = toForwardSlash(value);
|
|
644
|
+
return (replacePathPrefix(forward, absoluteFrom, absoluteTo)
|
|
645
|
+
?? replacePathPrefix(forward, relativeFrom, relativeTo)
|
|
646
|
+
?? value);
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
/** Re-anchor the `location` of every issue that names a staged path. */
|
|
650
|
+
function reanchorIssueLocations(issues, mapPath) {
|
|
651
|
+
return issues.map((issue) => {
|
|
652
|
+
if (issue.location === undefined)
|
|
653
|
+
return issue;
|
|
654
|
+
const location = mapPath(issue.location);
|
|
655
|
+
return location === issue.location ? issue : { ...issue, location };
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Re-anchor everything ONE skill's result says about where things are.
|
|
660
|
+
*
|
|
661
|
+
* Both post-build channels are rewritten, not just the one the summary reads:
|
|
662
|
+
* `collectPostBuildIssues` merges them and either can carry a staged location
|
|
663
|
+
* (the built-output validation runs against the staged `SKILL.md` itself), so a
|
|
664
|
+
* mapper applied to one of them leaves the report half-anchored.
|
|
665
|
+
*
|
|
666
|
+
* The `postBuildIssues` half is a live invariant with NO live producer today, and
|
|
667
|
+
* the distinction matters to anyone changing it. Every issue on that channel is
|
|
668
|
+
* anchored either on the bundle (`checkUnreferencedFiles`,
|
|
669
|
+
* `checkBrokenPackagedLinks`, `detectPackagedAgentInstructionFiles`,
|
|
670
|
+
* `checkPackagedTestInput` — all `relative(outputPath, …)`) or on the SOURCE
|
|
671
|
+
* project root (`droppedGlobMatchesToIssues`, `walkerExclusionsToIssues`,
|
|
672
|
+
* `deferredAssetsToIssues`, `FILENAME_COLLISION` — all `relative(projectRoot, …)`,
|
|
673
|
+
* where `projectRoot` is discovered from the skill's own source path). Neither
|
|
674
|
+
* spelling contains the staging prefix, so deleting this branch changes no
|
|
675
|
+
* fixture in the repo — which is exactly why it is unit-tested directly rather
|
|
676
|
+
* than through a build. Do not delete it on the strength of a green suite: the
|
|
677
|
+
* moment any producer anchors on `outputPath` ABSOLUTELY, this is the only thing
|
|
678
|
+
* standing between a report and a path its reader cannot open.
|
|
679
|
+
*
|
|
680
|
+
* Exported for that unit test.
|
|
681
|
+
*/
|
|
682
|
+
export function reanchorStagedResult(result, mapPath) {
|
|
683
|
+
const reanchored = { ...result, outputPath: mapPath(result.outputPath) };
|
|
684
|
+
if (result.postBuildIssues) {
|
|
685
|
+
reanchored.postBuildIssues = reanchorIssueLocations(result.postBuildIssues, mapPath);
|
|
686
|
+
}
|
|
687
|
+
if (result.postBuildValidation) {
|
|
688
|
+
reanchored.postBuildValidation = {
|
|
689
|
+
...result.postBuildValidation,
|
|
690
|
+
allErrors: reanchorIssueLocations(result.postBuildValidation.allErrors, mapPath),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return reanchored;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Move the previous output aside and open a staging root for this run.
|
|
697
|
+
*
|
|
698
|
+
* Three properties the placement is load-bearing for:
|
|
699
|
+
*
|
|
700
|
+
* 1. **Same filesystem.** The staging root is a SIBLING of `dist/skills` under
|
|
701
|
+
* `dist/`, so promotion is a `rename` — atomic and free — never a
|
|
702
|
+
* cross-device copy of a tree that can be tens of thousands of files.
|
|
703
|
+
* 2. **Invisible to the run.** `createProjectRegistry` crawls the project for
|
|
704
|
+
* `**\/*.md` and excludes `**\/dist/**`, so nothing staged here can enter the
|
|
705
|
+
* registry the packager resolves links against. The leading dot is a second
|
|
706
|
+
* belt: a `files:` glob like `dist/**` does not descend into dot-directories.
|
|
707
|
+
* 3. **The final path is ABSENT while the build runs.** The previous tree is
|
|
708
|
+
* renamed away before the first bundle is written, which is exactly what the
|
|
709
|
+
* delete-up-front flow guaranteed — so no lane can read a half-replaced
|
|
710
|
+
* `dist/skills`, and a stale bundle cannot leak into the new output.
|
|
711
|
+
*
|
|
712
|
+
* `mkdtemp` rather than a fixed name: two builds in one `dist/` must not share a
|
|
713
|
+
* staging root, and a leftover root from a killed run must not be adopted.
|
|
714
|
+
*
|
|
715
|
+
* KNOWN WINDOW, deliberately not closed here: a run killed between the park and
|
|
716
|
+
* the commit/abort (Ctrl-C on a multi-minute build, a CI timeout, a crash) leaves
|
|
717
|
+
* `dist/skills` absent and the previous output parked at
|
|
718
|
+
* `dist/.vat-skills-<rand>.previous`. That is no worse than the delete-up-front
|
|
719
|
+
* flow this replaces — there, the same interrupt lost the tree outright — and the
|
|
720
|
+
* output is recoverable with a single `mv`. Auto-recovering it on the next run is
|
|
721
|
+
* NOT safe as long as `mkdtemp` allows concurrent builds in one `dist/`: a sweep
|
|
722
|
+
* cannot tell a dead run's parked tree from a live run's, and adopting the wrong
|
|
723
|
+
* one would restore stale bundles over fresh output. Closing this needs a lock on
|
|
724
|
+
* `dist/`, not a heuristic.
|
|
725
|
+
*
|
|
726
|
+
* That window covers KILLS only. A promotion that throws — EACCES, ENOSPC, or the
|
|
727
|
+
* ENOTEMPTY a concurrent build produces with no injection at all — is deterministic,
|
|
728
|
+
* needs no signal, and IS handled: see {@link BuildStaging.recover} and
|
|
729
|
+
* {@link settleStaging}.
|
|
730
|
+
*
|
|
731
|
+
* Exported for tests, which drive the primitives directly. Injecting a promotion
|
|
732
|
+
* failure through a whole `runSkillBuild` would mean racing the filesystem;
|
|
733
|
+
* holding the staging handle lets a test create the exact on-disk state
|
|
734
|
+
* (a target reoccupied between the park and the promotion) that produces one.
|
|
735
|
+
*/
|
|
736
|
+
export async function beginStagedBuild(cwd, onlySkill) {
|
|
737
|
+
const distDir = safePath.resolve(cwd, 'dist');
|
|
738
|
+
const skillsDir = safePath.join(distDir, 'skills');
|
|
739
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
740
|
+
await mkdir(distDir, { recursive: true });
|
|
741
|
+
const root = toForwardSlash(await mkdtemp(safePath.join(distDir, '.vat-skills-')));
|
|
742
|
+
// `--skill <name>` rebuilds ONE bundle, so the all-or-nothing guarantee is
|
|
743
|
+
// scoped to that bundle: its siblings under dist/skills are neither replaced
|
|
744
|
+
// nor set aside, exactly as the delete-just-that-directory flow behaved.
|
|
745
|
+
const subPath = onlySkill === undefined ? undefined : skillNameToFsPath(onlySkill);
|
|
746
|
+
const promoteFrom = subPath === undefined ? root : safePath.join(root, subPath);
|
|
747
|
+
const promoteTo = subPath === undefined ? skillsDir : safePath.join(skillsDir, subPath);
|
|
748
|
+
// `skillNameToFsPath` yields ONE path segment, so the parent is always known
|
|
749
|
+
// without a dirname call.
|
|
750
|
+
const promoteToParent = subPath === undefined ? distDir : skillsDir;
|
|
751
|
+
const parked = `${root}.previous`;
|
|
752
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
753
|
+
const hadPreviousOutput = existsSync(promoteTo);
|
|
754
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
755
|
+
if (hadPreviousOutput)
|
|
756
|
+
await rename(promoteTo, parked);
|
|
757
|
+
// Flipped the instant this run's bundles reach `promoteTo`, so a later failure
|
|
758
|
+
// in the SAME call (the parked-tree cleanup, the staging-shell removal) is not
|
|
759
|
+
// mistaken for "the output never landed". Without it, a `commit()` that renamed
|
|
760
|
+
// successfully and then failed to delete the parked copy would report
|
|
761
|
+
// `outputCommitted: false` about a `dist/skills` that holds the new output.
|
|
762
|
+
let promoted = false;
|
|
763
|
+
return {
|
|
764
|
+
root,
|
|
765
|
+
hadPreviousOutput,
|
|
766
|
+
parkedPath: parked,
|
|
767
|
+
promoteLabel: subPath === undefined
|
|
768
|
+
? DIST_SKILLS_LABEL
|
|
769
|
+
: `${DIST_SKILLS_LABEL}/${subPath}`,
|
|
770
|
+
promoted: () => promoted,
|
|
771
|
+
commit: async () => {
|
|
772
|
+
// Guarded because in single-skill mode the staged bundle is a SUBPATH that
|
|
773
|
+
// exists only if that one skill actually built. (In full-build mode the
|
|
774
|
+
// staging root always exists, so a run that built nothing promotes an
|
|
775
|
+
// empty dist/skills — an accurate statement of "this build produced no
|
|
776
|
+
// bundles", where the old delete-up-front flow left the path absent.)
|
|
777
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
778
|
+
if (existsSync(promoteFrom)) {
|
|
779
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
780
|
+
await mkdir(promoteToParent, { recursive: true });
|
|
781
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
782
|
+
await rename(promoteFrom, promoteTo);
|
|
783
|
+
}
|
|
784
|
+
promoted = true;
|
|
785
|
+
await rm(parked, { recursive: true, force: true });
|
|
786
|
+
// A no-op in full-build mode (the rename above consumed it); in
|
|
787
|
+
// single-skill mode this is the now-empty staging shell.
|
|
788
|
+
await rm(root, { recursive: true, force: true });
|
|
789
|
+
},
|
|
790
|
+
abort: async () => {
|
|
791
|
+
await rm(root, { recursive: true, force: true });
|
|
792
|
+
if (!hadPreviousOutput)
|
|
793
|
+
return;
|
|
794
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
795
|
+
await mkdir(promoteToParent, { recursive: true });
|
|
796
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
797
|
+
await rename(parked, promoteTo);
|
|
798
|
+
},
|
|
799
|
+
recover: async () => {
|
|
800
|
+
const residue = [];
|
|
801
|
+
let restoredPrevious = false;
|
|
802
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
803
|
+
const targetFree = !existsSync(promoteTo);
|
|
804
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
805
|
+
if (hadPreviousOutput && existsSync(parked)) {
|
|
806
|
+
if (targetFree) {
|
|
807
|
+
try {
|
|
808
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
809
|
+
await mkdir(promoteToParent, { recursive: true });
|
|
810
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
|
|
811
|
+
await rename(parked, promoteTo);
|
|
812
|
+
restoredPrevious = true;
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
// The repair is best-effort by construction: it runs because the
|
|
816
|
+
// filesystem already refused something. A throw here must not replace
|
|
817
|
+
// the original diagnosis, so the parked path is reported as residue
|
|
818
|
+
// and the caller still names the real cause.
|
|
819
|
+
residue.push(parked);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
residue.push(parked);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// This run's own staging root is always safe to drop: it holds output this
|
|
827
|
+
// run produced and can rebuild. Leaving it is what accumulated a complete
|
|
828
|
+
// copy of the build output in `dist/` on every failed promotion.
|
|
829
|
+
try {
|
|
830
|
+
await rm(root, { recursive: true, force: true });
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
residue.push(root);
|
|
834
|
+
}
|
|
835
|
+
return { restoredPrevious, residue };
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Promote or discard the staged tree, and never leave the operator without an
|
|
841
|
+
* answer when that fails.
|
|
842
|
+
*
|
|
843
|
+
* `commit()` / `abort()` used to be called bare. Any throw on that path — the
|
|
844
|
+
* promotion `rename`, the restore `rename`, either cleanup — propagated to
|
|
845
|
+
* `buildCommand`'s catch, which exits 2 WITHOUT emitting the YAML document. The
|
|
846
|
+
* measured result: `dist/skills` absent, the user's previous output orphaned at
|
|
847
|
+
* `dist/.vat-skills-<rand>.previous` under a name the `mkdtemp` suffix makes
|
|
848
|
+
* unguessable, and not one byte of the report whose `outputCommitted` field
|
|
849
|
+
* `--help` tells an operator to read when they see a non-zero exit.
|
|
850
|
+
*
|
|
851
|
+
* So: repair what can be repaired, then return a message rather than throwing —
|
|
852
|
+
* the caller publishes the document first and exits 2 after.
|
|
853
|
+
*
|
|
854
|
+
* `outputCommitted` comes from {@link BuildStaging.promoted}, not from
|
|
855
|
+
* `!runFailed`: a `commit()` that renamed the tree into place and then failed to
|
|
856
|
+
* delete the parked copy DID replace `dist/skills`, and saying otherwise would
|
|
857
|
+
* put the report back into contradiction with the disk — the one thing this whole
|
|
858
|
+
* summary exists to prevent.
|
|
859
|
+
*/
|
|
860
|
+
export async function settleStaging(staging, runFailed, logger) {
|
|
861
|
+
try {
|
|
862
|
+
await (runFailed ? staging.abort() : staging.commit());
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
const recovery = await staging.recover();
|
|
866
|
+
return {
|
|
867
|
+
outputCommitted: staging.promoted(),
|
|
868
|
+
promotionError: describePromotionFailure(staging, error, recovery),
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
if (runFailed) {
|
|
872
|
+
logger.error(staging.hadPreviousOutput
|
|
873
|
+
? `\n Nothing was replaced — the previous ${staging.promoteLabel} is intact`
|
|
874
|
+
: `\n Nothing was written — ${staging.promoteLabel} does not exist`);
|
|
875
|
+
}
|
|
876
|
+
return { outputCommitted: !runFailed };
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* State the failure, then state what is on disk and how to get back.
|
|
880
|
+
*
|
|
881
|
+
* Every branch names an absolute path the operator can act on. A promotion
|
|
882
|
+
* failure is the one moment where "your previous output is at <path>" is the
|
|
883
|
+
* whole remedy, and the path is unreconstructable without being told.
|
|
884
|
+
*/
|
|
885
|
+
function describePromotionFailure(staging, error, recovery) {
|
|
886
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
887
|
+
const lines = [`Build output promotion failed: ${cause}`];
|
|
888
|
+
if (staging.promoted()) {
|
|
889
|
+
lines.push(` ${staging.promoteLabel} DOES hold this run's output — the failure was in the cleanup that follows.`);
|
|
890
|
+
}
|
|
891
|
+
else if (recovery.restoredPrevious) {
|
|
892
|
+
lines.push(` The previous ${staging.promoteLabel} has been restored; nothing this run built was kept.`);
|
|
893
|
+
}
|
|
894
|
+
else if (staging.hadPreviousOutput) {
|
|
895
|
+
lines.push(` The previous ${staging.promoteLabel} is parked at ${staging.parkedPath}`, ` Recover it with: mv ${staging.parkedPath} <your ${staging.promoteLabel}>`);
|
|
264
896
|
}
|
|
265
897
|
else {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
898
|
+
lines.push(` ${staging.promoteLabel} was never written, and there was no previous output to lose.`);
|
|
899
|
+
}
|
|
900
|
+
for (const path of recovery.residue) {
|
|
901
|
+
lines.push(` Left on disk (this run could not remove it): ${path}`);
|
|
902
|
+
}
|
|
903
|
+
return lines.join('\n');
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Validate, package, and drain — the whole span of ONE `vat build` invocation.
|
|
907
|
+
*
|
|
908
|
+
* The allow-usage ledger created here spans EVERY skill and BOTH validation
|
|
909
|
+
* lanes (the source-tree pre-build check and the two lanes inside
|
|
910
|
+
* `packageSkill`), because `validation.allow` is declared once for the package
|
|
911
|
+
* while being evaluated once per skill per lane. Anything narrower reports
|
|
912
|
+
* entries that legitimately matched somewhere else as unused: measured on this
|
|
913
|
+
* repo's own 13-skill package, 3 live entries produced 32 false ALLOW_UNUSED
|
|
914
|
+
* warnings — 6 from the cross-skill seam, 26 because the two lanes inside
|
|
915
|
+
* `packageSkill` see a FILTERED issue population against a file packaging has
|
|
916
|
+
* renamed to `SKILL.md`, so entries scoped to a source filename are structurally
|
|
917
|
+
* incapable of matching there. A lane that sees a subset cannot answer "matched
|
|
918
|
+
* nothing"; only the union can. Hence one ledger, drained once, here.
|
|
919
|
+
*
|
|
920
|
+
* Extracted from the command body so the span is testable without driving
|
|
921
|
+
* `process.exit` — the drain seam is the thing worth asserting. Nothing in here
|
|
922
|
+
* exits: every failure of every kind is COLLECTED and returned, so one run
|
|
923
|
+
* surfaces all the work rather than the first item of it.
|
|
924
|
+
*/
|
|
925
|
+
export async function runSkillBuild(input) {
|
|
926
|
+
const { specs, cwd, logger, projectSkills, onlySkill, verbose } = input;
|
|
927
|
+
const allowLedger = createAllowUsageLedger();
|
|
928
|
+
const staging = await beginStagedBuild(cwd, onlySkill);
|
|
929
|
+
// Validate every skill before building ANY of them, and keep going past the
|
|
930
|
+
// ones that fail: a rejected source is a finding to report, not a reason to
|
|
931
|
+
// stop looking. The path logged is the FINAL one — it is where the bundle will
|
|
932
|
+
// live if this run earns the swap, and the staging path is an implementation
|
|
933
|
+
// detail no reader should have to decode.
|
|
934
|
+
const buildable = [];
|
|
935
|
+
const validationFailures = [];
|
|
936
|
+
for (const spec of specs) {
|
|
937
|
+
const { skill, packagingConfig } = spec;
|
|
938
|
+
logger.info(`\nBuilding skill: ${skill.name}`);
|
|
939
|
+
logger.info(` Source: ${skill.sourcePath}`);
|
|
940
|
+
logger.info(` Output: ${finalOutputPath(cwd, skill.name)}`);
|
|
941
|
+
const failure = await validateSkillBeforeBuild({
|
|
942
|
+
skillName: skill.name,
|
|
943
|
+
sourcePath: skill.sourcePath,
|
|
944
|
+
packagingConfig,
|
|
945
|
+
logger,
|
|
946
|
+
locationRoot: cwd,
|
|
947
|
+
allowLedger,
|
|
948
|
+
projectSkills,
|
|
949
|
+
verbose,
|
|
950
|
+
});
|
|
951
|
+
if (failure) {
|
|
952
|
+
validationFailures.push(failure);
|
|
953
|
+
continue;
|
|
270
954
|
}
|
|
955
|
+
buildable.push(spec);
|
|
956
|
+
}
|
|
957
|
+
// Build the skills that qualified, with a shared registry.
|
|
958
|
+
//
|
|
959
|
+
// `projectSkills` is the run's declared eval suites — the whole project's, not
|
|
960
|
+
// `specs`'. `--skill x` narrows what gets BUILT; it never narrows what counts as
|
|
961
|
+
// test input, because an excluded skill's suite is still an answer key that must
|
|
962
|
+
// not ship inside x's bundle.
|
|
963
|
+
const buildSpecs = buildable.map(({ skill, packagingConfig }) => ({
|
|
964
|
+
skillPath: skill.sourcePath,
|
|
965
|
+
options: packagingConfigToPackageOptions(packagingConfig, {
|
|
966
|
+
skillPath: skill.sourcePath,
|
|
967
|
+
// Into staging, not dist/skills: see `beginStagedBuild`.
|
|
968
|
+
outputPath: safePath.join(staging.root, skillNameToFsPath(skill.name)),
|
|
969
|
+
}, projectSkills),
|
|
970
|
+
}));
|
|
971
|
+
const outcomes = await packageSkills(buildSpecs, cwd, allowLedger);
|
|
972
|
+
const results = [];
|
|
973
|
+
const skillsWithErrors = [];
|
|
974
|
+
const failures = [];
|
|
975
|
+
const mapStagedPath = createStagingPathMapper(cwd, staging.root);
|
|
976
|
+
let collapsedFindings = 0;
|
|
977
|
+
for (const [i, spec] of buildable.entries()) {
|
|
978
|
+
const outcome = outcomes[i];
|
|
979
|
+
if (!outcome)
|
|
980
|
+
continue;
|
|
981
|
+
const skillName = spec.skill.name;
|
|
982
|
+
if (outcome.status === 'failed') {
|
|
983
|
+
// No file-count line here: nothing was built, and claiming a count for an
|
|
984
|
+
// absent bundle is the misreport this branch exists to avoid.
|
|
985
|
+
logger.error(`\nBuild failed for skill: ${skillName}`);
|
|
986
|
+
logger.error(` ${outcome.error.message}`);
|
|
987
|
+
failures.push({ name: skillName, message: outcome.error.message });
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
// Re-anchored BEFORE anything reads it — the report below and the published
|
|
991
|
+
// row both. The bundle was written under staging, and staging is transient
|
|
992
|
+
// in both outcomes, so any path that survives this call unmapped is a path
|
|
993
|
+
// its reader cannot open. See `createStagingPathMapper`.
|
|
994
|
+
const result = reanchorStagedResult(outcome.result, mapStagedPath);
|
|
995
|
+
// Named, because this line is printed in a SECOND pass: the validation pass
|
|
996
|
+
// above emits every `Building skill: <name>` banner first, so at scale (92
|
|
997
|
+
// banners, then 86 outcomes) an unnamed count line sits under an unrelated
|
|
998
|
+
// skill's banner and reads as that skill's result.
|
|
999
|
+
logger.info(` ${skillName}: built ${formatPackagedFileCount(result)}`);
|
|
1000
|
+
collapsedFindings += logPostBuildIssues(skillName, result, logger, verbose);
|
|
1001
|
+
if (result.hasErrors) {
|
|
1002
|
+
skillsWithErrors.push(skillName);
|
|
1003
|
+
}
|
|
1004
|
+
results.push({ name: skillName, result });
|
|
1005
|
+
}
|
|
1006
|
+
// ONE hint for the run, after every skill has reported — the shape `vat audit`
|
|
1007
|
+
// already uses. Per skill it would repeat 86 times on the adopter run that
|
|
1008
|
+
// motivated it; omitted entirely (which is how this shipped) the collapsed
|
|
1009
|
+
// block is a heading with nothing under it and no way to learn there is more.
|
|
1010
|
+
const collapsedHint = formatCollapsedFindingsHint(collapsedFindings, 'build');
|
|
1011
|
+
if (collapsedHint !== undefined)
|
|
1012
|
+
logger.info(collapsedHint);
|
|
1013
|
+
// Drain point: every skill and every lane has now contributed, so this is the
|
|
1014
|
+
// first place the run can honestly say an entry matched nothing. A skill that
|
|
1015
|
+
// threw still contributed the matches it made before throwing — which is why
|
|
1016
|
+
// the drain must stay here, after a partially-failed batch, and not move into
|
|
1017
|
+
// a success-only path. (Before per-skill containment the drain never ran at
|
|
1018
|
+
// all on a throw, because the exception propagated straight past it.)
|
|
1019
|
+
//
|
|
1020
|
+
// Known residual: a skill that threw never reached its later lanes, so an
|
|
1021
|
+
// allow entry only THAT skill could have matched may now be reported
|
|
1022
|
+
// ALLOW_UNUSED. That is a warning, and `runHasErrors` gates on `error` only,
|
|
1023
|
+
// so it cannot fail a build on its own — and the run is already exiting 1 on
|
|
1024
|
+
// the failure the operator actually needs to fix.
|
|
1025
|
+
const runIssues = allowUnusedIssues(allowLedger);
|
|
1026
|
+
// ONE predicate, evaluated once, for BOTH "does dist/skills get replaced" and
|
|
1027
|
+
// (via `outputCommitted`) "what does the command exit with". Two definitions
|
|
1028
|
+
// of "this run failed" is how a report ends up disagreeing with the disk and
|
|
1029
|
+
// with the exit code — the failure mode this whole summary exists to prevent.
|
|
1030
|
+
// A run-level ALLOW_UNUSED is a `warning`, so it is not in here: a warning must
|
|
1031
|
+
// never discard artifacts that were produced correctly.
|
|
1032
|
+
const runFailed = failures.length > 0
|
|
1033
|
+
|| validationFailures.length > 0
|
|
1034
|
+
|| skillsWithErrors.length > 0
|
|
1035
|
+
|| runIssues.some((i) => i.severity === 'error');
|
|
1036
|
+
const { outputCommitted, promotionError } = await settleStaging(staging, runFailed, logger);
|
|
1037
|
+
return {
|
|
1038
|
+
results,
|
|
1039
|
+
runIssues,
|
|
1040
|
+
skillsWithErrors,
|
|
1041
|
+
failures,
|
|
1042
|
+
validationFailures,
|
|
1043
|
+
outputCommitted,
|
|
1044
|
+
...(promotionError === undefined ? {} : { promotionError }),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Name every way this run failed, on stderr, one section per population.
|
|
1049
|
+
*
|
|
1050
|
+
* All four sections print — a run can fail in several ways at once, and the
|
|
1051
|
+
* whole point of collecting rather than aborting is that ONE build cycle shows
|
|
1052
|
+
* an adopter all of the work. A section that stopped at the first population
|
|
1053
|
+
* would put the fail-fast defect back one level up.
|
|
1054
|
+
*/
|
|
1055
|
+
function logRunFailures(run, logger) {
|
|
1056
|
+
const { failures, skillsWithErrors, validationFailures, runIssues } = run;
|
|
1057
|
+
if (validationFailures.length > 0) {
|
|
1058
|
+
logger.error(`\nBuild failed: ${validationFailures.length} skill(s) failed pre-build validation`);
|
|
1059
|
+
for (const { name } of validationFailures)
|
|
1060
|
+
logger.error(` - ${name}`);
|
|
1061
|
+
}
|
|
1062
|
+
if (failures.length > 0) {
|
|
1063
|
+
logger.error(`\nBuild failed: ${failures.length} skill(s) could not be packaged at all`);
|
|
1064
|
+
for (const { name, message } of failures) {
|
|
1065
|
+
logger.error(` - ${name}: ${message.split('\n')[0] ?? message}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (skillsWithErrors.length > 0) {
|
|
1069
|
+
logger.error(`\nBuild failed: ${skillsWithErrors.length} skill(s) emitted post-build validation errors`);
|
|
1070
|
+
for (const name of skillsWithErrors)
|
|
1071
|
+
logger.error(` - ${name}`);
|
|
1072
|
+
}
|
|
1073
|
+
// A run-level finding belongs to no skill, so the loops above cannot carry it.
|
|
1074
|
+
// Only `error` severity gates: ALLOW_UNUSED ships as a `warning`, and a
|
|
1075
|
+
// warning must never abort a build that produced its artifacts.
|
|
1076
|
+
if (runIssues.some((i) => i.severity === 'error')) {
|
|
1077
|
+
logger.error(`\nBuild failed: run-level validation errors (project config, not any one skill)`);
|
|
271
1078
|
}
|
|
272
1079
|
}
|
|
273
1080
|
async function buildCommand(pathArg, options) {
|
|
@@ -294,59 +1101,56 @@ async function buildCommand(pathArg, options) {
|
|
|
294
1101
|
// Filter by skill name if specified
|
|
295
1102
|
const skillsToBuild = filterSkillsByName(discoveredSkills, options.skill);
|
|
296
1103
|
logger.info(`Found ${skillsToBuild.length} skill(s) to build`);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
1104
|
+
// Handle dry-run mode. Nothing has touched `dist/` at this point — the
|
|
1105
|
+
// staging directory and the swap both live inside `runSkillBuild`, which a
|
|
1106
|
+
// dry run never reaches, so "preview without creating files" is now true of
|
|
1107
|
+
// the OUTPUT TREE as well as of the bundles. (The old flow deleted
|
|
1108
|
+
// `dist/skills` before this branch and relied on an `if (!options.dryRun)`
|
|
1109
|
+
// guard three lines up to stay honest.)
|
|
301
1110
|
if (options.dryRun) {
|
|
302
1111
|
const duration = Date.now() - startTime;
|
|
303
1112
|
performDryRun(skillsToBuild, duration, logger);
|
|
304
1113
|
process.exit(0);
|
|
305
1114
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const packagingConfig = mergeSkillPackagingConfig(skillsConfig.defaults, skillsConfig.config?.[skill.name]);
|
|
310
|
-
const outputDir = safePath.resolve(cwd, 'dist', 'skills', skillNameToFsPath(skill.name));
|
|
311
|
-
logger.info(`\nBuilding skill: ${skill.name}`);
|
|
312
|
-
logger.info(` Source: ${skill.sourcePath}`);
|
|
313
|
-
logger.info(` Output: ${outputDir}`);
|
|
314
|
-
await validateSkillOrExit(skill.name, skill.sourcePath, packagingConfig, logger, cwd);
|
|
315
|
-
validatedSpecs.push({ skill, packagingConfig });
|
|
316
|
-
}
|
|
317
|
-
// Build all skills with a shared registry
|
|
318
|
-
const specs = validatedSpecs.map(({ skill, packagingConfig }) => ({
|
|
319
|
-
skillPath: skill.sourcePath,
|
|
320
|
-
options: packagingConfigToPackageOptions(packagingConfig, {
|
|
321
|
-
skillPath: skill.sourcePath,
|
|
322
|
-
outputPath: safePath.resolve(cwd, 'dist', 'skills', skillNameToFsPath(skill.name)),
|
|
323
|
-
}),
|
|
1115
|
+
const buildSpecs = skillsToBuild.map((skill) => ({
|
|
1116
|
+
skill,
|
|
1117
|
+
packagingConfig: mergeSkillPackagingConfig(skillsConfig.defaults, skillsConfig.config?.[skill.name]),
|
|
324
1118
|
}));
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
results.push({ name: spec.skill.name, result });
|
|
337
|
-
}
|
|
338
|
-
}
|
|
1119
|
+
// Assembled ONCE, from the UNFILTERED discovery: `--skill x` narrows the build,
|
|
1120
|
+
// not the set of files that count as some skill's declared test input.
|
|
1121
|
+
const projectSkills = collectDeclaredEvalSuites(skillsConfig, discoveredSkills);
|
|
1122
|
+
const run = await runSkillBuild({
|
|
1123
|
+
specs: buildSpecs,
|
|
1124
|
+
cwd,
|
|
1125
|
+
logger,
|
|
1126
|
+
projectSkills,
|
|
1127
|
+
onlySkill: options.skill,
|
|
1128
|
+
verbose: options.verbose === true,
|
|
1129
|
+
});
|
|
339
1130
|
const duration = Date.now() - startTime;
|
|
340
1131
|
// Output YAML results
|
|
341
|
-
outputBuildYaml(
|
|
342
|
-
|
|
343
|
-
logger.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
1132
|
+
outputBuildYaml(run, duration);
|
|
1133
|
+
for (const line of formatRunIssueLines(run.runIssues)) {
|
|
1134
|
+
logger.info(line);
|
|
1135
|
+
}
|
|
1136
|
+
logRunFailures(run, logger);
|
|
1137
|
+
// A promotion failure is a SYSTEM error (2), not a validation one (1): the
|
|
1138
|
+
// build's verdict on the skills is not what went wrong, and a CI script has
|
|
1139
|
+
// to be able to tell "your skills are broken" from "the filesystem refused
|
|
1140
|
+
// and dist/skills is in a state someone must look at". Reported AFTER the
|
|
1141
|
+
// document is written, which is the whole reason it rides on the run rather
|
|
1142
|
+
// than being thrown from inside it.
|
|
1143
|
+
if (run.promotionError !== undefined) {
|
|
1144
|
+
logger.error(`\n${run.promotionError}`);
|
|
1145
|
+
process.exit(2);
|
|
1146
|
+
}
|
|
1147
|
+
// Gated on the SAME fact that decided whether the swap happened, not on a
|
|
1148
|
+
// second copy of the predicate: the exit code and `outputCommitted` can then
|
|
1149
|
+
// never disagree about whether this run succeeded.
|
|
1150
|
+
if (!run.outputCommitted) {
|
|
347
1151
|
process.exit(1);
|
|
348
1152
|
}
|
|
349
|
-
logger.info(`\nBuilt ${results.length} skill(s) successfully`);
|
|
1153
|
+
logger.info(`\nBuilt ${run.results.length} skill(s) successfully`);
|
|
350
1154
|
process.exit(0);
|
|
351
1155
|
}
|
|
352
1156
|
catch (error) {
|