swarmdo 1.18.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/helpers/statusline.cjs +1 -1
- package/README.md +2 -1
- package/package.json +1 -1
- package/v3/@swarmdo/cli/dist/src/apply/apply.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +29 -2
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +26 -3
- package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.d.ts +26 -7
- package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +97 -13
- package/v3/@swarmdo/cli/dist/src/codegraph/store.d.ts +8 -1
- package/v3/@swarmdo/cli/dist/src/codegraph/store.js +78 -1
- package/v3/@swarmdo/cli/dist/src/commands/cycles.js +4 -1
- package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +5 -2
- package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -3
- package/v3/@swarmdo/cli/dist/src/commands/release.js +5 -1
- package/v3/@swarmdo/cli/dist/src/commands/testreport.d.ts +17 -0
- package/v3/@swarmdo/cli/dist/src/commands/testreport.js +120 -0
- package/v3/@swarmdo/cli/dist/src/cycles/cycles.d.ts +12 -2
- package/v3/@swarmdo/cli/dist/src/cycles/cycles.js +5 -3
- package/v3/@swarmdo/cli/dist/src/env/env.d.ts +5 -2
- package/v3/@swarmdo/cli/dist/src/env/env.js +24 -3
- package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +15 -0
- package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +33 -1
- package/v3/@swarmdo/cli/dist/src/license/license.d.ts +31 -2
- package/v3/@swarmdo/cli/dist/src/license/license.js +133 -11
- package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/testreport-tools.d.ts +11 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/testreport-tools.js +33 -0
- package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +2 -3
- package/v3/@swarmdo/cli/dist/src/pack/pack.js +68 -12
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +14 -2
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +16 -0
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +28 -1
- package/v3/@swarmdo/cli/dist/src/testreport/testreport.d.ts +65 -0
- package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +246 -0
- package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.d.ts +6 -0
- package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.js +12 -9
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +4 -1
- package/v3/@swarmdo/cli/package.json +1 -1
|
@@ -80,7 +80,15 @@ function renderMarkdown(files, opts) {
|
|
|
80
80
|
return parts.join('\n');
|
|
81
81
|
}
|
|
82
82
|
function escapeXml(s) {
|
|
83
|
-
|
|
83
|
+
// All five predefined XML entities — the quote escapes matter because this
|
|
84
|
+
// value is also interpolated into a double-quoted `path="…"` attribute, where
|
|
85
|
+
// an unescaped `"` in a file path would break out of the attribute.
|
|
86
|
+
return s
|
|
87
|
+
.replace(/&/g, '&')
|
|
88
|
+
.replace(/</g, '<')
|
|
89
|
+
.replace(/>/g, '>')
|
|
90
|
+
.replace(/"/g, '"')
|
|
91
|
+
.replace(/'/g, ''');
|
|
84
92
|
}
|
|
85
93
|
function renderXml(files, opts) {
|
|
86
94
|
const parts = ['<repository>'];
|
|
@@ -138,11 +146,42 @@ export function packFiles(input, opts = {}) {
|
|
|
138
146
|
},
|
|
139
147
|
};
|
|
140
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Convert a .gitignore glob body to a regex source (no anchors). Handles a
|
|
151
|
+
* double-star with correct gitignore(5) depth semantics: a double-star path
|
|
152
|
+
* segment matches zero or more directories (so `a/[star][star]/b` matches
|
|
153
|
+
* `a/b`, `a/x/b`, `a/x/y/b`); a leading one matches in any directory; a
|
|
154
|
+
* trailing one matches everything below. Single `*`/`?` stay within one path
|
|
155
|
+
* segment; other consecutive asterisks degrade to regular stars, per the spec.
|
|
156
|
+
* Pure.
|
|
157
|
+
*/
|
|
158
|
+
function globToRegExp(body) {
|
|
159
|
+
const esc = (s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]');
|
|
160
|
+
const parts = body.split('/');
|
|
161
|
+
let re = '';
|
|
162
|
+
for (let i = 0; i < parts.length; i++) {
|
|
163
|
+
const last = i === parts.length - 1;
|
|
164
|
+
if (parts[i] === '**') {
|
|
165
|
+
if (last) {
|
|
166
|
+
re += '.*';
|
|
167
|
+
} // trailing /** — everything below
|
|
168
|
+
else {
|
|
169
|
+
re += '(?:[^/]*/)*';
|
|
170
|
+
continue;
|
|
171
|
+
} // absorbs the following slash → zero-or-more dirs
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
re += esc(parts[i]);
|
|
175
|
+
}
|
|
176
|
+
if (!last)
|
|
177
|
+
re += '/';
|
|
178
|
+
}
|
|
179
|
+
return re;
|
|
180
|
+
}
|
|
141
181
|
/**
|
|
142
182
|
* Minimal .gitignore-style matcher. Handles the common cases — plain names,
|
|
143
|
-
* `dir/`, `*.ext` globs,
|
|
144
|
-
*
|
|
145
|
-
* Good enough to keep obvious noise out; full semantics is a follow-up.
|
|
183
|
+
* `dir/`, `*.ext` globs, `**` depth globs (gitignore(5) semantics), leading `/`
|
|
184
|
+
* anchors, and `!` negation. Not the full spec (no nested ignore files).
|
|
146
185
|
*/
|
|
147
186
|
export function makeIgnoreMatcher(patterns) {
|
|
148
187
|
const rules = patterns
|
|
@@ -157,22 +196,39 @@ export function makeIgnoreMatcher(patterns) {
|
|
|
157
196
|
const anchored = body.startsWith('/');
|
|
158
197
|
if (anchored)
|
|
159
198
|
body = body.slice(1);
|
|
160
|
-
const re = new RegExp('^' +
|
|
161
|
-
body
|
|
162
|
-
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
163
|
-
.replace(/\*/g, '[^/]*')
|
|
164
|
-
.replace(/\?/g, '[^/]') +
|
|
165
|
-
(dirOnly ? '(/|$)' : '($|/)'));
|
|
199
|
+
const re = new RegExp('^' + globToRegExp(body) + (dirOnly ? '(/|$)' : '($|/)'));
|
|
166
200
|
return { negate, anchored, re };
|
|
167
201
|
});
|
|
168
|
-
|
|
202
|
+
// Ignored status of a single path (last-match-wins over the ordered rules).
|
|
203
|
+
const ruleVerdict = (path) => {
|
|
169
204
|
let ignored = false;
|
|
170
205
|
for (const r of rules) {
|
|
171
|
-
const candidates = r.anchored ? [
|
|
206
|
+
const candidates = r.anchored ? [path] : [path, ...path.split('/').map((_, i, a) => a.slice(i).join('/'))];
|
|
172
207
|
if (candidates.some((c) => r.re.test(c)))
|
|
173
208
|
ignored = !r.negate;
|
|
174
209
|
}
|
|
175
210
|
return ignored;
|
|
176
211
|
};
|
|
212
|
+
return (relPath) => {
|
|
213
|
+
// Walk ancestor dirs top-down: once a parent directory is excluded, the file
|
|
214
|
+
// stays excluded — a negation cannot re-include under an excluded parent
|
|
215
|
+
// (gitignore(5): "not possible to re-include a file if a parent directory of
|
|
216
|
+
// that file is excluded").
|
|
217
|
+
const parts = relPath.split('/').filter(Boolean);
|
|
218
|
+
let parentIgnored = false;
|
|
219
|
+
for (let i = 0; i < parts.length; i++) {
|
|
220
|
+
const isLast = i === parts.length - 1;
|
|
221
|
+
if (parentIgnored) {
|
|
222
|
+
if (isLast)
|
|
223
|
+
return true;
|
|
224
|
+
continue; // excluded ancestor carries down
|
|
225
|
+
}
|
|
226
|
+
const verdict = ruleVerdict(parts.slice(0, i + 1).join('/'));
|
|
227
|
+
if (isLast)
|
|
228
|
+
return verdict;
|
|
229
|
+
parentIgnored = verdict; // this directory prefix carries forward
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
};
|
|
177
233
|
}
|
|
178
234
|
//# sourceMappingURL=pack.js.map
|
|
@@ -26,6 +26,7 @@ export const RULES = [
|
|
|
26
26
|
{ id: 'anthropic-key', description: 'Anthropic API key', regex: /\bsk-ant-[0-9A-Za-z-]{16,}\b/g },
|
|
27
27
|
{ id: 'openai-key', description: 'OpenAI API key', regex: /\bsk-(?:proj-)?[0-9A-Za-z]{20,}\b/g },
|
|
28
28
|
{ id: 'google-api-key', description: 'Google API key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
29
|
+
{ id: 'google-oauth-token', description: 'Google OAuth access token', regex: /\bya29\.[0-9A-Za-z_-]{20,}/g },
|
|
29
30
|
{ id: 'slack-token', description: 'Slack token', regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
|
|
30
31
|
{ id: 'slack-webhook', description: 'Slack incoming webhook', regex: /https:\/\/hooks\.slack\.com\/services\/T[0-9A-Za-z_]+\/B[0-9A-Za-z_]+\/[0-9A-Za-z_]+/g },
|
|
31
32
|
{ id: 'stripe-key', description: 'Stripe secret/restricted key', regex: /\b(?:sk|rk)_live_[0-9A-Za-z]{24,}\b/g },
|
|
@@ -33,9 +34,20 @@ export const RULES = [
|
|
|
33
34
|
{ id: 'twilio-key', description: 'Twilio API key SID', regex: /\bSK[0-9a-fA-F]{32}\b/g },
|
|
34
35
|
{ id: 'npm-token', description: 'npm access token', regex: /\bnpm_[0-9A-Za-z]{36}\b/g },
|
|
35
36
|
{ id: 'jwt', description: 'JSON Web Token', regex: /\beyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}\b/g },
|
|
37
|
+
// RFC 6750 Bearer credential. Last (most generic): specific token rules above
|
|
38
|
+
// claim their range first. 16+ b64token chars keeps prose ("Bearer document")
|
|
39
|
+
// from matching; only the token (group 1) is masked, not the `Bearer ` prefix.
|
|
40
|
+
{ id: 'bearer-token', description: 'HTTP Bearer token (RFC 6750)', regex: /\bBearer\s+([A-Za-z0-9\-._~+/]{16,}=*)/g, group: 1 },
|
|
36
41
|
];
|
|
37
|
-
/**
|
|
38
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Keyword-prefixed assignment, e.g. `api_key = "..."` — the value is group 1.
|
|
44
|
+
* Keyword set mirrors gitleaks' reference `generic-api-key` rule (adds bare
|
|
45
|
+
* `key`, `credential`, `creds` so `PRIVATE_KEY=`/`ENCRYPTION_KEY=`/`DB_CREDENTIAL=`
|
|
46
|
+
* secrets reach the entropy check). The `[:=]` immediately after the keyword
|
|
47
|
+
* anchors it, so `keyboard=`/`monkey_val=` don't match; the entropy + length
|
|
48
|
+
* gates keep low-entropy values (paths, short flags) from being flagged.
|
|
49
|
+
*/
|
|
50
|
+
const ASSIGNMENT_RE = /(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
|
|
39
51
|
/** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
|
|
40
52
|
export function shannonEntropy(s) {
|
|
41
53
|
if (!s)
|
|
@@ -21,6 +21,7 @@ export interface Component {
|
|
|
21
21
|
content: string;
|
|
22
22
|
};
|
|
23
23
|
dev?: boolean;
|
|
24
|
+
optional?: boolean;
|
|
24
25
|
}
|
|
25
26
|
interface NpmLockEntry {
|
|
26
27
|
version?: string;
|
|
@@ -53,6 +54,21 @@ export interface BomMeta {
|
|
|
53
54
|
name: string;
|
|
54
55
|
version: string;
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Is this npm `license` string an SPDX *expression* (compound), rather than a
|
|
59
|
+
* single license identifier? npm permits expressions like `(MIT OR Apache-2.0)`
|
|
60
|
+
* or `Apache-2.0 WITH LLVM-exception` for dual/multi-licensed packages. SPDX
|
|
61
|
+
* operators are uppercase and space-delimited, so a single ID (`MIT`,
|
|
62
|
+
* `BSD-3-Clause`, `CC-BY-SA-4.0`) never matches. Pure.
|
|
63
|
+
*/
|
|
64
|
+
export declare function isSpdxExpression(license: string): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* A CycloneDX `licenses[]` entry for a license string. CycloneDX requires
|
|
67
|
+
* `license.id` to be a single valid SPDX identifier, so an expression must use
|
|
68
|
+
* the sibling `expression` field instead — otherwise strict validators and
|
|
69
|
+
* scanners (Dependency-Track, Grype) reject or mis-parse the component. Pure.
|
|
70
|
+
*/
|
|
71
|
+
export declare function cdxLicenseEntry(license: string): Record<string, unknown>;
|
|
56
72
|
/** Build a CycloneDX 1.5 BOM object (no timestamp → deterministic). Pure. */
|
|
57
73
|
export declare function buildCycloneDX(components: Component[], meta: BomMeta): Record<string, unknown>;
|
|
58
74
|
/** Build a minimal SPDX 2.3 JSON document (no created timestamp → deterministic). Pure. */
|
|
@@ -77,11 +77,32 @@ export function componentsFromNpmLock(lock, opts = {}) {
|
|
|
77
77
|
}
|
|
78
78
|
if (entry.dev)
|
|
79
79
|
comp.dev = true;
|
|
80
|
+
if (entry.optional)
|
|
81
|
+
comp.optional = true;
|
|
80
82
|
out.push(comp);
|
|
81
83
|
}
|
|
82
84
|
out.sort((a, b) => (a.name === b.name ? a.version.localeCompare(b.version) : a.name.localeCompare(b.name)));
|
|
83
85
|
return out;
|
|
84
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Is this npm `license` string an SPDX *expression* (compound), rather than a
|
|
89
|
+
* single license identifier? npm permits expressions like `(MIT OR Apache-2.0)`
|
|
90
|
+
* or `Apache-2.0 WITH LLVM-exception` for dual/multi-licensed packages. SPDX
|
|
91
|
+
* operators are uppercase and space-delimited, so a single ID (`MIT`,
|
|
92
|
+
* `BSD-3-Clause`, `CC-BY-SA-4.0`) never matches. Pure.
|
|
93
|
+
*/
|
|
94
|
+
export function isSpdxExpression(license) {
|
|
95
|
+
return / (OR|AND|WITH) /.test(license) || license.includes('(');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A CycloneDX `licenses[]` entry for a license string. CycloneDX requires
|
|
99
|
+
* `license.id` to be a single valid SPDX identifier, so an expression must use
|
|
100
|
+
* the sibling `expression` field instead — otherwise strict validators and
|
|
101
|
+
* scanners (Dependency-Track, Grype) reject or mis-parse the component. Pure.
|
|
102
|
+
*/
|
|
103
|
+
export function cdxLicenseEntry(license) {
|
|
104
|
+
return isSpdxExpression(license) ? { expression: license } : { license: { id: license } };
|
|
105
|
+
}
|
|
85
106
|
/** Build a CycloneDX 1.5 BOM object (no timestamp → deterministic). Pure. */
|
|
86
107
|
export function buildCycloneDX(components, meta) {
|
|
87
108
|
return {
|
|
@@ -93,10 +114,16 @@ export function buildCycloneDX(components, meta) {
|
|
|
93
114
|
},
|
|
94
115
|
components: components.map((c) => {
|
|
95
116
|
const comp = { type: 'library', name: c.name, version: c.version, purl: c.purl };
|
|
117
|
+
// CycloneDX 1.5 `scope`: dev-only deps are `excluded` (not shipped),
|
|
118
|
+
// optional deps are `optional`; a required runtime dep omits it (implicit
|
|
119
|
+
// `required`), keeping the common case clean. dev wins over optional.
|
|
120
|
+
const scope = c.dev ? 'excluded' : c.optional ? 'optional' : undefined;
|
|
121
|
+
if (scope)
|
|
122
|
+
comp.scope = scope;
|
|
96
123
|
if (c.hash)
|
|
97
124
|
comp.hashes = [{ alg: c.hash.alg, content: c.hash.content }];
|
|
98
125
|
if (c.license)
|
|
99
|
-
comp.licenses = [
|
|
126
|
+
comp.licenses = [cdxLicenseEntry(c.license)];
|
|
100
127
|
return comp;
|
|
101
128
|
}),
|
|
102
129
|
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testreport.ts — parse JUnit-XML / TAP test results into a compact failure
|
|
3
|
+
* digest. The missing front-half of the test→fix loop: an agent runs the suite,
|
|
4
|
+
* points this at the results, and gets exactly the failing test names + file:line
|
|
5
|
+
* + assertion message — instead of re-reading hundreds of log lines — then feeds
|
|
6
|
+
* that into `repair`. Inspired by dorny/test-reporter, action-junit-report,
|
|
7
|
+
* gotestsum.
|
|
8
|
+
*
|
|
9
|
+
* Pure + deterministic: a results string in, a normalized summary out. The file
|
|
10
|
+
* read / glob lives in ../commands/testreport.ts, so this is fixture-testable.
|
|
11
|
+
* No XML dependency — well-formed JUnit escapes <>& in attribute values, so a
|
|
12
|
+
* focused tokenizer over <testcase>/<failure> is safe.
|
|
13
|
+
*/
|
|
14
|
+
export type TestStatus = 'passed' | 'failed' | 'skipped';
|
|
15
|
+
export type TestFormat = 'junit' | 'tap';
|
|
16
|
+
export interface TestFailure {
|
|
17
|
+
suite: string;
|
|
18
|
+
name: string;
|
|
19
|
+
file?: string;
|
|
20
|
+
line?: number;
|
|
21
|
+
message?: string;
|
|
22
|
+
type?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface TestSummary {
|
|
25
|
+
passed: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
skipped: number;
|
|
28
|
+
total: number;
|
|
29
|
+
durationMs: number;
|
|
30
|
+
failures: TestFailure[];
|
|
31
|
+
/**
|
|
32
|
+
* TAP `# TODO` tests — "not expected to succeed" per the TAP spec, so a
|
|
33
|
+
* `not ok … # TODO` is a KNOWN-incomplete stub, counted here as a success,
|
|
34
|
+
* never a failure. Absent (0) for JUnit and TODO-free TAP.
|
|
35
|
+
*/
|
|
36
|
+
todo?: number;
|
|
37
|
+
/**
|
|
38
|
+
* True if a TAP `Bail out!` line aborted the run. The counts are then
|
|
39
|
+
* INCOMPLETE — the suite stopped early, so a "0 failed" here does NOT mean
|
|
40
|
+
* the suite passed. Callers/CI must treat this as a failure.
|
|
41
|
+
*/
|
|
42
|
+
bailedOut?: boolean;
|
|
43
|
+
/** Optional reason text following `Bail out!`. */
|
|
44
|
+
bailReason?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Pull a `file:line` out of a stack-trace / assertion message. Pure. */
|
|
47
|
+
export declare function extractFileLine(text: string): {
|
|
48
|
+
file?: string;
|
|
49
|
+
line?: number;
|
|
50
|
+
};
|
|
51
|
+
/** Parse JUnit XML (testsuites/testsuite/testcase). Pure. */
|
|
52
|
+
export declare function parseJUnit(xml: string): TestSummary;
|
|
53
|
+
/** Parse TAP (Test Anything Protocol) output. Pure. */
|
|
54
|
+
export declare function parseTAP(text: string): TestSummary;
|
|
55
|
+
/** Sniff the format from a path extension, then content. Pure. */
|
|
56
|
+
export declare function detectFormat(content: string, path?: string): TestFormat;
|
|
57
|
+
/** Parse by explicit or detected format. Pure. */
|
|
58
|
+
export declare function parseTestReport(content: string, format: TestFormat): TestSummary;
|
|
59
|
+
/** Merge several summaries (multi-file globs). Pure. */
|
|
60
|
+
export declare function mergeSummaries(list: TestSummary[]): TestSummary;
|
|
61
|
+
/** Human-readable digest. Pure. */
|
|
62
|
+
export declare function formatSummary(s: TestSummary, opts?: {
|
|
63
|
+
top?: number;
|
|
64
|
+
}): string;
|
|
65
|
+
//# sourceMappingURL=testreport.d.ts.map
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testreport.ts — parse JUnit-XML / TAP test results into a compact failure
|
|
3
|
+
* digest. The missing front-half of the test→fix loop: an agent runs the suite,
|
|
4
|
+
* points this at the results, and gets exactly the failing test names + file:line
|
|
5
|
+
* + assertion message — instead of re-reading hundreds of log lines — then feeds
|
|
6
|
+
* that into `repair`. Inspired by dorny/test-reporter, action-junit-report,
|
|
7
|
+
* gotestsum.
|
|
8
|
+
*
|
|
9
|
+
* Pure + deterministic: a results string in, a normalized summary out. The file
|
|
10
|
+
* read / glob lives in ../commands/testreport.ts, so this is fixture-testable.
|
|
11
|
+
* No XML dependency — well-formed JUnit escapes <>& in attribute values, so a
|
|
12
|
+
* focused tokenizer over <testcase>/<failure> is safe.
|
|
13
|
+
*/
|
|
14
|
+
/** Decode the five predefined XML entities. Pure. */
|
|
15
|
+
function decodeXml(s) {
|
|
16
|
+
return s
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"')
|
|
20
|
+
.replace(/'/g, "'")
|
|
21
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(parseInt(d, 10)))
|
|
22
|
+
.replace(/&/g, '&'); // last, so &lt; → < not <
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Decode an element's text content, honoring CDATA sections. Text OUTSIDE
|
|
26
|
+
* `<![CDATA[ … ]]>` is entity-decoded; text INSIDE is taken literally (per the
|
|
27
|
+
* XML spec, CDATA content is never entity-expanded). Maven Surefire/Failsafe
|
|
28
|
+
* wrap `<failure>`/`<error>` stack traces in CDATA, so without this the raw
|
|
29
|
+
* `<![CDATA[`/`]]>` markers leak into the failure message. Pure.
|
|
30
|
+
*/
|
|
31
|
+
function decodeXmlContent(s) {
|
|
32
|
+
const OPEN = '<![CDATA[';
|
|
33
|
+
const CLOSE = ']]>';
|
|
34
|
+
if (!s.includes(OPEN))
|
|
35
|
+
return decodeXml(s);
|
|
36
|
+
let out = '';
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < s.length) {
|
|
39
|
+
const start = s.indexOf(OPEN, i);
|
|
40
|
+
if (start < 0) {
|
|
41
|
+
out += decodeXml(s.slice(i));
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
out += decodeXml(s.slice(i, start));
|
|
45
|
+
const end = s.indexOf(CLOSE, start + OPEN.length);
|
|
46
|
+
if (end < 0) {
|
|
47
|
+
out += s.slice(start + OPEN.length);
|
|
48
|
+
break;
|
|
49
|
+
} // unterminated → rest is literal
|
|
50
|
+
out += s.slice(start + OPEN.length, end);
|
|
51
|
+
i = end + CLOSE.length;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** Extract key="value" / key='value' attributes from a tag's attribute text. Pure. */
|
|
56
|
+
function parseAttrs(attrText) {
|
|
57
|
+
const out = {};
|
|
58
|
+
const re = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = re.exec(attrText)) !== null) {
|
|
61
|
+
out[m[1]] = decodeXml(m[3] ?? m[4] ?? '');
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** Pull a `file:line` out of a stack-trace / assertion message. Pure. */
|
|
66
|
+
export function extractFileLine(text) {
|
|
67
|
+
if (!text)
|
|
68
|
+
return {};
|
|
69
|
+
// Prefer a parenthesised frame `(path:line:col)`, else a bare `path:line`.
|
|
70
|
+
const paren = text.match(/\(([^()\s]+?):(\d+):(?:\d+)\)/);
|
|
71
|
+
if (paren)
|
|
72
|
+
return { file: paren[1], line: parseInt(paren[2], 10) };
|
|
73
|
+
const bare = text.match(/([\w./\\-]+\.[a-zA-Z]{1,5}):(\d+)(?::\d+)?/);
|
|
74
|
+
if (bare)
|
|
75
|
+
return { file: bare[1], line: parseInt(bare[2], 10) };
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
/** Parse JUnit XML (testsuites/testsuite/testcase). Pure. */
|
|
79
|
+
export function parseJUnit(xml) {
|
|
80
|
+
const failures = [];
|
|
81
|
+
let passed = 0, failed = 0, skipped = 0, durationMs = 0;
|
|
82
|
+
const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
83
|
+
let c;
|
|
84
|
+
while ((c = caseRe.exec(xml)) !== null) {
|
|
85
|
+
const attrs = parseAttrs(c[1]);
|
|
86
|
+
const body = c[2] ?? '';
|
|
87
|
+
const name = attrs.name ?? '(unnamed)';
|
|
88
|
+
const suite = attrs.classname ?? attrs.suite ?? '';
|
|
89
|
+
if (attrs.time)
|
|
90
|
+
durationMs += Math.round(parseFloat(attrs.time) * 1000) || 0;
|
|
91
|
+
const fail = body.match(/<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/);
|
|
92
|
+
const isSkipped = /<skipped\b[^>]*\/?>/.test(body);
|
|
93
|
+
if (fail) {
|
|
94
|
+
failed++;
|
|
95
|
+
const fAttrs = parseAttrs(fail[2]);
|
|
96
|
+
const inner = decodeXmlContent(fail[3] ?? '').trim();
|
|
97
|
+
const message = fAttrs.message || inner.split('\n')[0] || undefined;
|
|
98
|
+
// Prefer explicit file/line attrs (pytest, some emitters), else sniff the trace.
|
|
99
|
+
const loc = attrs.file
|
|
100
|
+
? { file: attrs.file, line: attrs.line ? parseInt(attrs.line, 10) : undefined }
|
|
101
|
+
: extractFileLine(inner || fAttrs.message || '');
|
|
102
|
+
failures.push({ suite, name, message, type: fAttrs.type, ...loc });
|
|
103
|
+
}
|
|
104
|
+
else if (isSkipped) {
|
|
105
|
+
skipped++;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
passed++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { passed, failed, skipped, total: passed + failed + skipped, durationMs, failures };
|
|
112
|
+
}
|
|
113
|
+
/** Parse TAP (Test Anything Protocol) output. Pure. */
|
|
114
|
+
export function parseTAP(text) {
|
|
115
|
+
const failures = [];
|
|
116
|
+
let passed = 0, failed = 0, skipped = 0, todo = 0;
|
|
117
|
+
let bailedOut = false;
|
|
118
|
+
let bailReason;
|
|
119
|
+
const lines = text.split('\n');
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
const line = lines[i];
|
|
122
|
+
// `Bail out!` (column 1, per the TAP spec) aborts the run — stop parsing;
|
|
123
|
+
// anything after it is not a valid result.
|
|
124
|
+
const bail = line.match(/^Bail out!(?:\s+(.*))?$/);
|
|
125
|
+
if (bail) {
|
|
126
|
+
bailedOut = true;
|
|
127
|
+
bailReason = bail[1]?.trim() || undefined;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const m = line.match(/^(ok|not ok)\b\s*(\d+)?\s*-?\s*(.*)$/);
|
|
131
|
+
if (!m)
|
|
132
|
+
continue;
|
|
133
|
+
const ok = m[1] === 'ok';
|
|
134
|
+
let desc = m[3].trim();
|
|
135
|
+
const directive = desc.match(/#\s*(SKIP|TODO)\b(.*)$/i);
|
|
136
|
+
if (directive)
|
|
137
|
+
desc = desc.slice(0, directive.index).trim();
|
|
138
|
+
if (directive && /skip/i.test(directive[1])) {
|
|
139
|
+
skipped++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// TODO tests are "not expected to succeed" — a `not ok … # TODO` is a known
|
|
143
|
+
// stub, not a real failure (TAP spec). Count it as todo, never a failure.
|
|
144
|
+
if (directive && /todo/i.test(directive[1])) {
|
|
145
|
+
todo++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (ok) {
|
|
149
|
+
passed++;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
failed++;
|
|
153
|
+
// Look ahead for an indented YAML diagnostic block for message/file/line.
|
|
154
|
+
let message;
|
|
155
|
+
let file;
|
|
156
|
+
let lineNo;
|
|
157
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
158
|
+
const d = lines[j];
|
|
159
|
+
if (!/^\s/.test(d) && d.trim() !== '')
|
|
160
|
+
break; // dedented → block ended
|
|
161
|
+
const mm = d.match(/^\s*message:\s*(.+)$/);
|
|
162
|
+
if (mm)
|
|
163
|
+
message = mm[1].replace(/^["']|["']$/g, '').trim();
|
|
164
|
+
const fm = d.match(/^\s*(?:file|at):\s*(.+)$/);
|
|
165
|
+
if (fm) {
|
|
166
|
+
const fl = extractFileLine(fm[1]);
|
|
167
|
+
if (fl.file) {
|
|
168
|
+
file = fl.file;
|
|
169
|
+
lineNo = fl.line;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const lm = d.match(/^\s*line:\s*(\d+)/);
|
|
173
|
+
if (lm)
|
|
174
|
+
lineNo = parseInt(lm[1], 10);
|
|
175
|
+
if (/^\s*\.\.\.\s*$/.test(d))
|
|
176
|
+
break; // end of YAML block
|
|
177
|
+
}
|
|
178
|
+
failures.push({ suite: '', name: desc || '(unnamed)', message, file, line: lineNo });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { passed, failed, skipped, total: passed + failed + skipped + todo, durationMs: 0, failures, ...(todo && { todo }), ...(bailedOut && { bailedOut, bailReason }) };
|
|
182
|
+
}
|
|
183
|
+
/** Sniff the format from a path extension, then content. Pure. */
|
|
184
|
+
export function detectFormat(content, path) {
|
|
185
|
+
if (path) {
|
|
186
|
+
if (/\.tap$/i.test(path))
|
|
187
|
+
return 'tap';
|
|
188
|
+
if (/\.xml$/i.test(path))
|
|
189
|
+
return 'junit';
|
|
190
|
+
}
|
|
191
|
+
if (/<testsuite|<testcase/i.test(content))
|
|
192
|
+
return 'junit';
|
|
193
|
+
if (/^\s*(TAP version|1\.\.\d|ok\b|not ok\b)/m.test(content))
|
|
194
|
+
return 'tap';
|
|
195
|
+
return 'junit';
|
|
196
|
+
}
|
|
197
|
+
/** Parse by explicit or detected format. Pure. */
|
|
198
|
+
export function parseTestReport(content, format) {
|
|
199
|
+
return format === 'tap' ? parseTAP(content) : parseJUnit(content);
|
|
200
|
+
}
|
|
201
|
+
/** Merge several summaries (multi-file globs). Pure. */
|
|
202
|
+
export function mergeSummaries(list) {
|
|
203
|
+
const merged = list.reduce((acc, s) => ({
|
|
204
|
+
passed: acc.passed + s.passed,
|
|
205
|
+
failed: acc.failed + s.failed,
|
|
206
|
+
skipped: acc.skipped + s.skipped,
|
|
207
|
+
total: acc.total + s.total,
|
|
208
|
+
durationMs: acc.durationMs + s.durationMs,
|
|
209
|
+
failures: acc.failures.concat(s.failures),
|
|
210
|
+
}), { passed: 0, failed: 0, skipped: 0, total: 0, durationMs: 0, failures: [] });
|
|
211
|
+
const todoSum = list.reduce((n, s) => n + (s.todo ?? 0), 0);
|
|
212
|
+
if (todoSum > 0)
|
|
213
|
+
merged.todo = todoSum;
|
|
214
|
+
// Any bailed file taints the whole run; keep the first reason seen.
|
|
215
|
+
const bailed = list.find((s) => s.bailedOut);
|
|
216
|
+
if (bailed) {
|
|
217
|
+
merged.bailedOut = true;
|
|
218
|
+
merged.bailReason = bailed.bailReason;
|
|
219
|
+
}
|
|
220
|
+
return merged;
|
|
221
|
+
}
|
|
222
|
+
/** Human-readable digest. Pure. */
|
|
223
|
+
export function formatSummary(s, opts = {}) {
|
|
224
|
+
const todoSeg = s.todo ? ` · ${s.todo} todo` : '';
|
|
225
|
+
const head = `${s.passed} passed · ${s.failed} failed · ${s.skipped} skipped${todoSeg} (${s.total} total, ${s.durationMs}ms)`;
|
|
226
|
+
const bailLine = s.bailedOut
|
|
227
|
+
? `⚠ suite ABORTED (Bail out!${s.bailReason ? `: ${s.bailReason}` : ''}) — results incomplete`
|
|
228
|
+
: '';
|
|
229
|
+
if (s.failures.length === 0) {
|
|
230
|
+
if (s.bailedOut)
|
|
231
|
+
return `${bailLine}\n${head}`;
|
|
232
|
+
return head + (s.failed === 0 ? ' ✓' : '');
|
|
233
|
+
}
|
|
234
|
+
const shown = opts.top && opts.top > 0 ? s.failures.slice(0, opts.top) : s.failures;
|
|
235
|
+
const lines = s.bailedOut ? [bailLine, head, ''] : [head, ''];
|
|
236
|
+
for (const f of shown) {
|
|
237
|
+
const where = f.file ? `${f.file}${f.line ? `:${f.line}` : ''}` : '';
|
|
238
|
+
lines.push(`✗ ${f.suite ? f.suite + ' › ' : ''}${f.name}${where ? ` (${where})` : ''}`);
|
|
239
|
+
if (f.message)
|
|
240
|
+
lines.push(` ${f.message.split('\n')[0]}`);
|
|
241
|
+
}
|
|
242
|
+
if (shown.length < s.failures.length)
|
|
243
|
+
lines.push(`… and ${s.failures.length - shown.length} more`);
|
|
244
|
+
return lines.join('\n');
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=testreport.js.map
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
export interface TranscriptModelPrice {
|
|
17
17
|
in: number;
|
|
18
18
|
out: number;
|
|
19
|
+
/** 5-minute-TTL cache write (1.25× base input) */
|
|
19
20
|
cacheWrite: number;
|
|
21
|
+
/** 1-hour-TTL cache write (2× base input) */
|
|
22
|
+
cacheWrite1h: number;
|
|
20
23
|
cacheRead: number;
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
@@ -32,7 +35,10 @@ export declare function resolveTranscriptPrice(rawModelId: string): TranscriptMo
|
|
|
32
35
|
export interface TokenBundle {
|
|
33
36
|
inputTokens: number;
|
|
34
37
|
outputTokens: number;
|
|
38
|
+
/** TOTAL cache-write tokens (5-min + 1-hour). */
|
|
35
39
|
cacheWriteTokens: number;
|
|
40
|
+
/** The 1-hour-TTL SUBSET of cacheWriteTokens (≤ cacheWriteTokens). Default 0 → all writes priced at the 5-min rate. */
|
|
41
|
+
cacheWrite1hTokens?: number;
|
|
36
42
|
cacheReadTokens: number;
|
|
37
43
|
}
|
|
38
44
|
/** USD for one response at the given rates. */
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
* publishes rates — absent means "unpriced", never "guessed".
|
|
21
21
|
*/
|
|
22
22
|
const PRICE_FAMILIES = {
|
|
23
|
-
'claude-opus-4': { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
24
|
-
'claude-sonnet-4': { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
25
|
-
'claude-haiku-4': { in: 1, out: 5, cacheWrite: 1.25, cacheRead: 0.1 },
|
|
26
|
-
'claude-3-7-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
27
|
-
'claude-3-5-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
28
|
-
'claude-3-5-haiku': { in: 0.8, out: 4, cacheWrite: 1, cacheRead: 0.08 },
|
|
29
|
-
'claude-3-opus': { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
30
|
-
'claude-3-haiku': { in: 0.25, out: 1.25, cacheWrite: 0.3, cacheRead: 0.03 },
|
|
23
|
+
'claude-opus-4': { in: 15, out: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
24
|
+
'claude-sonnet-4': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
25
|
+
'claude-haiku-4': { in: 1, out: 5, cacheWrite: 1.25, cacheWrite1h: 2, cacheRead: 0.1 },
|
|
26
|
+
'claude-3-7-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
27
|
+
'claude-3-5-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
28
|
+
'claude-3-5-haiku': { in: 0.8, out: 4, cacheWrite: 1, cacheWrite1h: 1.6, cacheRead: 0.08 },
|
|
29
|
+
'claude-3-opus': { in: 15, out: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
30
|
+
'claude-3-haiku': { in: 0.25, out: 1.25, cacheWrite: 0.3, cacheWrite1h: 0.5, cacheRead: 0.03 },
|
|
31
31
|
};
|
|
32
32
|
/**
|
|
33
33
|
* Reduce a transcript/gateway model id to the bare Anthropic id:
|
|
@@ -62,9 +62,12 @@ export function resolveTranscriptPrice(rawModelId) {
|
|
|
62
62
|
}
|
|
63
63
|
/** USD for one response at the given rates. */
|
|
64
64
|
export function transcriptCostUsd(price, t) {
|
|
65
|
+
const cacheWrite1h = t.cacheWrite1hTokens ?? 0;
|
|
66
|
+
const cacheWrite5m = Math.max(0, t.cacheWriteTokens - cacheWrite1h);
|
|
65
67
|
return ((t.inputTokens * price.in +
|
|
66
68
|
t.outputTokens * price.out +
|
|
67
|
-
|
|
69
|
+
cacheWrite5m * price.cacheWrite +
|
|
70
|
+
cacheWrite1h * price.cacheWrite1h +
|
|
68
71
|
t.cacheReadTokens * price.cacheRead) / 1_000_000);
|
|
69
72
|
}
|
|
70
73
|
//# sourceMappingURL=claude-pricing.js.map
|
|
@@ -177,6 +177,9 @@ function toUsageEvent(line, project, seen, unpriced) {
|
|
|
177
177
|
cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
|
|
178
178
|
cacheReadTokens: usage.cache_read_input_tokens ?? 0,
|
|
179
179
|
};
|
|
180
|
+
// 1-hour-TTL cache writes cost 2× base input, not the 1.25× 5-min rate. Recent
|
|
181
|
+
// transcripts split them in `cache_creation`; older ones don't (→ all 5-min).
|
|
182
|
+
const cacheWrite1hTokens = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
180
183
|
let costUsd;
|
|
181
184
|
let costSource;
|
|
182
185
|
const price = resolveTranscriptPrice(model);
|
|
@@ -185,7 +188,7 @@ function toUsageEvent(line, project, seen, unpriced) {
|
|
|
185
188
|
costSource = 'transcript';
|
|
186
189
|
}
|
|
187
190
|
else if (price) {
|
|
188
|
-
costUsd = transcriptCostUsd(price, tokens);
|
|
191
|
+
costUsd = transcriptCostUsd(price, { ...tokens, cacheWrite1hTokens });
|
|
189
192
|
costSource = 'computed';
|
|
190
193
|
}
|
|
191
194
|
else {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmdo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Swarmdo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|