@sweny-ai/core 0.1.53 → 0.1.55
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/__tests__/executor.test.js +2 -1
- package/dist/__tests__/integration/sources.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/sources.integration.test.js +123 -0
- package/dist/__tests__/workflow-yaml.test.js +12 -5
- package/dist/cli/__tests__/report-cloud.test.d.ts +1 -0
- package/dist/cli/__tests__/report-cloud.test.js +165 -0
- package/dist/cli/cloud-report.d.ts +12 -0
- package/dist/cli/cloud-report.js +79 -0
- package/dist/cli/config-file.d.ts +3 -3
- package/dist/cli/config-file.js +18 -0
- package/dist/cli/config.d.ts +3 -0
- package/dist/cli/config.js +18 -2
- package/dist/cli/config.test.js +65 -1
- package/dist/cli/e2e.js +4 -2
- package/dist/cli/e2e.test.js +9 -2
- package/dist/cli/main.js +46 -19
- package/dist/cli/new.d.ts +0 -5
- package/dist/cli/new.js +0 -5
- package/dist/cli/new.test.js +1 -9
- package/dist/cli/output.test.js +1 -0
- package/dist/cli/publish.js +23 -12
- package/dist/cli/publish.test.d.ts +1 -0
- package/dist/cli/publish.test.js +610 -0
- package/dist/executor.d.ts +8 -0
- package/dist/executor.js +21 -3
- package/dist/executor.test.js +56 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mermaid.test.js +2 -0
- package/dist/schema.d.ts +174 -46
- package/dist/schema.js +31 -2
- package/dist/sources.d.ts +70 -0
- package/dist/sources.js +194 -0
- package/dist/sources.test.d.ts +1 -0
- package/dist/sources.test.js +264 -0
- package/dist/sources.validation.test.d.ts +12 -0
- package/dist/sources.validation.test.js +360 -0
- package/dist/templates.d.ts +6 -2
- package/dist/templates.js +36 -46
- package/dist/testing.js +4 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.js +0 -5
- package/dist/workflows/browser.js +1 -1
- package/dist/workflows/triage.yml +1 -23
- package/package.json +1 -5
package/dist/schema.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* parsing untrusted input (JSON/YAML import, Studio editor).
|
|
8
8
|
*/
|
|
9
9
|
import { z } from "zod";
|
|
10
|
+
import { sourceZ } from "./sources.js";
|
|
11
|
+
export { sourceZ };
|
|
10
12
|
// ─── Zod Schemas ─────────────────────────────────────────────────
|
|
11
13
|
export const jsonSchemaZ = z.record(z.unknown());
|
|
12
14
|
export const configFieldZ = z.object({
|
|
@@ -63,7 +65,7 @@ export const skillZ = z
|
|
|
63
65
|
});
|
|
64
66
|
export const nodeZ = z.object({
|
|
65
67
|
name: z.string().min(1),
|
|
66
|
-
instruction:
|
|
68
|
+
instruction: sourceZ,
|
|
67
69
|
skills: z.array(z.string()).default([]),
|
|
68
70
|
output: jsonSchemaZ.optional(),
|
|
69
71
|
});
|
|
@@ -232,6 +234,32 @@ export function validateWorkflow(workflow, knownSkills) {
|
|
|
232
234
|
* Static JSON Schema for the Workflow type.
|
|
233
235
|
* Use this for external validation (YAML files, Studio import, CI checks).
|
|
234
236
|
*/
|
|
237
|
+
const sourceJsonSchema = {
|
|
238
|
+
oneOf: [
|
|
239
|
+
{ type: "string", minLength: 1 },
|
|
240
|
+
{
|
|
241
|
+
type: "object",
|
|
242
|
+
properties: { inline: { type: "string" } },
|
|
243
|
+
required: ["inline"],
|
|
244
|
+
additionalProperties: false,
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
type: "object",
|
|
248
|
+
properties: { file: { type: "string", minLength: 1 } },
|
|
249
|
+
required: ["file"],
|
|
250
|
+
additionalProperties: false,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
type: "object",
|
|
254
|
+
properties: {
|
|
255
|
+
url: { type: "string", format: "uri" },
|
|
256
|
+
type: { type: "string" },
|
|
257
|
+
},
|
|
258
|
+
required: ["url"],
|
|
259
|
+
additionalProperties: false,
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
};
|
|
235
263
|
export const workflowJsonSchema = {
|
|
236
264
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
237
265
|
$id: "https://sweny.ai/schemas/workflow.json",
|
|
@@ -240,6 +268,7 @@ export const workflowJsonSchema = {
|
|
|
240
268
|
type: "object",
|
|
241
269
|
required: ["id", "name", "nodes", "edges", "entry"],
|
|
242
270
|
additionalProperties: false,
|
|
271
|
+
$defs: { Source: sourceJsonSchema },
|
|
243
272
|
properties: {
|
|
244
273
|
id: { type: "string", minLength: 1 },
|
|
245
274
|
name: { type: "string", minLength: 1 },
|
|
@@ -253,7 +282,7 @@ export const workflowJsonSchema = {
|
|
|
253
282
|
additionalProperties: false,
|
|
254
283
|
properties: {
|
|
255
284
|
name: { type: "string", minLength: 1 },
|
|
256
|
-
instruction: {
|
|
285
|
+
instruction: { $ref: "#/$defs/Source", description: "What Claude should do at this node" },
|
|
257
286
|
skills: {
|
|
258
287
|
type: "array",
|
|
259
288
|
items: { type: "string" },
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source type — unified shape for every content-bearing field in a workflow
|
|
3
|
+
* (instructions, rules, context, templates). Supports inline text, relative or
|
|
4
|
+
* absolute file paths, and HTTP(S) URLs via a single ergonomic form.
|
|
5
|
+
*
|
|
6
|
+
* See docs/superpowers/specs/2026-04-12-unified-source-type-design.md.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import type { Logger } from "./types.js";
|
|
10
|
+
export type Source = string | {
|
|
11
|
+
inline: string;
|
|
12
|
+
} | {
|
|
13
|
+
file: string;
|
|
14
|
+
} | {
|
|
15
|
+
url: string;
|
|
16
|
+
type?: string;
|
|
17
|
+
};
|
|
18
|
+
export type SourceKind = "inline" | "file" | "url";
|
|
19
|
+
/**
|
|
20
|
+
* Zod schema for a Source. Accepts either a non-empty string (classified by
|
|
21
|
+
* prefix) or one of three tagged object forms: {inline}, {file}, {url,type?}.
|
|
22
|
+
* Runtime validation of `type` against the resolver registry happens later —
|
|
23
|
+
* the schema stays permissive so new resolvers slot in without schema bumps.
|
|
24
|
+
*/
|
|
25
|
+
export declare const sourceZ: z.ZodUnion<[z.ZodString, z.ZodObject<{
|
|
26
|
+
inline: z.ZodString;
|
|
27
|
+
}, "strict", z.ZodTypeAny, {
|
|
28
|
+
inline: string;
|
|
29
|
+
}, {
|
|
30
|
+
inline: string;
|
|
31
|
+
}>, z.ZodObject<{
|
|
32
|
+
file: z.ZodString;
|
|
33
|
+
}, "strict", z.ZodTypeAny, {
|
|
34
|
+
file: string;
|
|
35
|
+
}, {
|
|
36
|
+
file: string;
|
|
37
|
+
}>, z.ZodObject<{
|
|
38
|
+
url: z.ZodString;
|
|
39
|
+
type: z.ZodOptional<z.ZodString>;
|
|
40
|
+
}, "strict", z.ZodTypeAny, {
|
|
41
|
+
url: string;
|
|
42
|
+
type?: string | undefined;
|
|
43
|
+
}, {
|
|
44
|
+
url: string;
|
|
45
|
+
type?: string | undefined;
|
|
46
|
+
}>]>;
|
|
47
|
+
/**
|
|
48
|
+
* Classify a plain-string Source by prefix. Throws on empty strings.
|
|
49
|
+
*/
|
|
50
|
+
export type ResolvedSource = {
|
|
51
|
+
content: string;
|
|
52
|
+
kind: SourceKind;
|
|
53
|
+
origin: Source;
|
|
54
|
+
resolver: "inline" | "file" | "fetch";
|
|
55
|
+
hash: string;
|
|
56
|
+
fetchedAt?: string;
|
|
57
|
+
sourcePath?: string;
|
|
58
|
+
};
|
|
59
|
+
export type SourceResolutionMap = Record<string, ResolvedSource>;
|
|
60
|
+
export declare function hashContent(content: string): string;
|
|
61
|
+
export declare function classifySource(raw: string): SourceKind;
|
|
62
|
+
export type SourceResolutionContext = {
|
|
63
|
+
cwd: string;
|
|
64
|
+
env: NodeJS.ProcessEnv;
|
|
65
|
+
authConfig: Record<string, string>;
|
|
66
|
+
offline: boolean;
|
|
67
|
+
logger: Logger;
|
|
68
|
+
};
|
|
69
|
+
export declare function resolveSource(source: Source, fieldPath: string, ctx: SourceResolutionContext): Promise<ResolvedSource>;
|
|
70
|
+
export declare function resolveSources(sources: Record<string, Source>, ctx: SourceResolutionContext): Promise<SourceResolutionMap>;
|
package/dist/sources.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source type — unified shape for every content-bearing field in a workflow
|
|
3
|
+
* (instructions, rules, context, templates). Supports inline text, relative or
|
|
4
|
+
* absolute file paths, and HTTP(S) URLs via a single ergonomic form.
|
|
5
|
+
*
|
|
6
|
+
* See docs/superpowers/specs/2026-04-12-unified-source-type-design.md.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
const inlineTagZ = z.object({ inline: z.string().min(1) }).strict();
|
|
13
|
+
const fileTagZ = z.object({ file: z.string().min(1) }).strict();
|
|
14
|
+
const urlTagZ = z
|
|
15
|
+
.object({
|
|
16
|
+
url: z.string().url(),
|
|
17
|
+
type: z.string().optional(),
|
|
18
|
+
})
|
|
19
|
+
.strict();
|
|
20
|
+
/**
|
|
21
|
+
* Zod schema for a Source. Accepts either a non-empty string (classified by
|
|
22
|
+
* prefix) or one of three tagged object forms: {inline}, {file}, {url,type?}.
|
|
23
|
+
* Runtime validation of `type` against the resolver registry happens later —
|
|
24
|
+
* the schema stays permissive so new resolvers slot in without schema bumps.
|
|
25
|
+
*/
|
|
26
|
+
export const sourceZ = z.union([z.string().min(1), inlineTagZ, fileTagZ, urlTagZ]);
|
|
27
|
+
export function hashContent(content) {
|
|
28
|
+
return createHash("sha256").update(content, "utf8").digest("hex").slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
export function classifySource(raw) {
|
|
31
|
+
const trimmed = raw.trim();
|
|
32
|
+
if (!trimmed) {
|
|
33
|
+
throw new Error("source error: empty string is not a valid Source");
|
|
34
|
+
}
|
|
35
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
36
|
+
return "url";
|
|
37
|
+
}
|
|
38
|
+
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("/")) {
|
|
39
|
+
return "file";
|
|
40
|
+
}
|
|
41
|
+
return "inline";
|
|
42
|
+
}
|
|
43
|
+
export async function resolveSource(source, fieldPath, ctx) {
|
|
44
|
+
const [kind, value] = normalizeSource(source);
|
|
45
|
+
if (kind === "inline") {
|
|
46
|
+
return {
|
|
47
|
+
content: value,
|
|
48
|
+
kind: "inline",
|
|
49
|
+
origin: source,
|
|
50
|
+
resolver: "inline",
|
|
51
|
+
hash: hashContent(value),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (kind === "file") {
|
|
55
|
+
const absolute = path.isAbsolute(value) ? value : path.resolve(ctx.cwd, value);
|
|
56
|
+
let content;
|
|
57
|
+
try {
|
|
58
|
+
content = await fs.readFile(absolute, "utf-8");
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const e = err;
|
|
62
|
+
if (e.code === "ENOENT") {
|
|
63
|
+
throw new Error(`SOURCE_FILE_NOT_FOUND: file not found: ${absolute} (referenced by ${fieldPath})`);
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`SOURCE_FILE_READ_FAILED: could not read ${absolute} (referenced by ${fieldPath}): ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
// Strip UTF-8 BOM (Windows editors emit this)
|
|
68
|
+
if (content.charCodeAt(0) === 0xfeff)
|
|
69
|
+
content = content.slice(1);
|
|
70
|
+
return {
|
|
71
|
+
content,
|
|
72
|
+
kind: "file",
|
|
73
|
+
origin: source,
|
|
74
|
+
resolver: "file",
|
|
75
|
+
hash: hashContent(content),
|
|
76
|
+
sourcePath: absolute,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (kind === "url") {
|
|
80
|
+
if (ctx.offline) {
|
|
81
|
+
throw new Error(`SOURCE_OFFLINE_REQUIRES_FETCH: cannot fetch ${value} in --offline mode (referenced by ${fieldPath}).`);
|
|
82
|
+
}
|
|
83
|
+
// Validate type field on tagged URL forms — v1 only accepts "fetch" (the default).
|
|
84
|
+
if (typeof source === "object" && "url" in source && source.type && source.type !== "fetch") {
|
|
85
|
+
throw new Error(`SOURCE_INVALID_TYPE: unknown resolver type "${source.type}" on ${value} (referenced by ${fieldPath}). v1 supports only "fetch".`);
|
|
86
|
+
}
|
|
87
|
+
const url = value;
|
|
88
|
+
const headers = buildFetchHeaders(url, ctx);
|
|
89
|
+
const res = await fetchWithRetry(url, headers, fieldPath);
|
|
90
|
+
if (res.status === 401 || res.status === 403) {
|
|
91
|
+
throw new Error(`SOURCE_URL_AUTH_REQUIRED: ${url} returned HTTP ${res.status} (referenced by ${fieldPath}). Configure fetch.auth in .sweny.yml or set SWENY_FETCH_TOKEN.`);
|
|
92
|
+
}
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(`SOURCE_URL_HTTP_ERROR: ${url} returned HTTP ${res.status} (referenced by ${fieldPath}).`);
|
|
95
|
+
}
|
|
96
|
+
const content = await res.text();
|
|
97
|
+
return {
|
|
98
|
+
content,
|
|
99
|
+
kind: "url",
|
|
100
|
+
origin: source,
|
|
101
|
+
resolver: "fetch",
|
|
102
|
+
hash: hashContent(content),
|
|
103
|
+
fetchedAt: new Date().toISOString(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`source error: unreachable (kind: ${kind}, field: ${fieldPath})`);
|
|
107
|
+
}
|
|
108
|
+
export async function resolveSources(sources, ctx) {
|
|
109
|
+
const cache = new Map();
|
|
110
|
+
const out = {};
|
|
111
|
+
const entries = Object.entries(sources);
|
|
112
|
+
await Promise.all(entries.map(async ([fieldPath, source]) => {
|
|
113
|
+
const key = canonicalKey(source, ctx);
|
|
114
|
+
let promise = key ? cache.get(key) : undefined;
|
|
115
|
+
if (!promise) {
|
|
116
|
+
promise = resolveSource(source, fieldPath, ctx);
|
|
117
|
+
if (key)
|
|
118
|
+
cache.set(key, promise);
|
|
119
|
+
}
|
|
120
|
+
out[fieldPath] = await promise;
|
|
121
|
+
}));
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function canonicalKey(source, ctx) {
|
|
125
|
+
const [kind, value] = normalizeSource(source);
|
|
126
|
+
if (kind === "file") {
|
|
127
|
+
return "file:" + (path.isAbsolute(value) ? value : path.resolve(ctx.cwd, value));
|
|
128
|
+
}
|
|
129
|
+
if (kind === "url")
|
|
130
|
+
return "url:" + value;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const RETRY_DELAYS = [250, 1000]; // ms — exponential backoff for 5xx
|
|
134
|
+
async function fetchWithRetry(url, headers, fieldPath) {
|
|
135
|
+
let lastError;
|
|
136
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
|
|
139
|
+
// Don't retry on 4xx (client errors) or 2xx (success)
|
|
140
|
+
if (res.status < 500)
|
|
141
|
+
return res;
|
|
142
|
+
// 5xx — retry if attempts remain
|
|
143
|
+
if (attempt < RETRY_DELAYS.length) {
|
|
144
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
return res; // final 5xx — let caller handle it
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
151
|
+
if (attempt < RETRY_DELAYS.length) {
|
|
152
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`SOURCE_URL_UNREACHABLE: ${url} (referenced by ${fieldPath}): ${lastError?.message ?? "unknown error"}. Pass --offline to skip URL sources.`);
|
|
158
|
+
}
|
|
159
|
+
function buildFetchHeaders(url, ctx) {
|
|
160
|
+
const headers = {
|
|
161
|
+
Accept: "text/plain, text/markdown, */*",
|
|
162
|
+
};
|
|
163
|
+
const token = resolveFetchToken(url, ctx);
|
|
164
|
+
if (token)
|
|
165
|
+
headers.Authorization = `Bearer ${token}`;
|
|
166
|
+
return headers;
|
|
167
|
+
}
|
|
168
|
+
function resolveFetchToken(url, ctx) {
|
|
169
|
+
let host;
|
|
170
|
+
try {
|
|
171
|
+
host = new URL(url).host;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
const envVar = ctx.authConfig[host];
|
|
177
|
+
if (envVar && ctx.env[envVar])
|
|
178
|
+
return ctx.env[envVar];
|
|
179
|
+
const fallback = ctx.env.SWENY_FETCH_TOKEN;
|
|
180
|
+
return fallback || undefined;
|
|
181
|
+
}
|
|
182
|
+
function normalizeSource(source) {
|
|
183
|
+
if (typeof source === "string") {
|
|
184
|
+
const kind = classifySource(source);
|
|
185
|
+
return [kind, source.trim()];
|
|
186
|
+
}
|
|
187
|
+
if ("inline" in source)
|
|
188
|
+
return ["inline", source.inline];
|
|
189
|
+
if ("file" in source)
|
|
190
|
+
return ["file", source.file.trim()];
|
|
191
|
+
if ("url" in source)
|
|
192
|
+
return ["url", source.url.trim()];
|
|
193
|
+
throw new Error("source error: unrecognised Source shape");
|
|
194
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
5
|
+
import { classifySource, sourceZ, hashContent, resolveSource, resolveSources } from "./sources.js";
|
|
6
|
+
const fileTmp = path.join(tmpdir(), "sweny-sources-file-test");
|
|
7
|
+
const baseCtx = () => ({
|
|
8
|
+
cwd: "/tmp",
|
|
9
|
+
env: {},
|
|
10
|
+
authConfig: {},
|
|
11
|
+
offline: false,
|
|
12
|
+
logger: { info: () => { }, warn: () => { }, error: () => { }, debug: () => { } },
|
|
13
|
+
});
|
|
14
|
+
describe("classifySource", () => {
|
|
15
|
+
it("classifies http(s) URLs as url", () => {
|
|
16
|
+
expect(classifySource("http://example.com/x.md")).toBe("url");
|
|
17
|
+
expect(classifySource("https://example.com/x.md")).toBe("url");
|
|
18
|
+
});
|
|
19
|
+
it("classifies ./ ../ and / paths as file", () => {
|
|
20
|
+
expect(classifySource("./local.md")).toBe("file");
|
|
21
|
+
expect(classifySource("../sibling.md")).toBe("file");
|
|
22
|
+
expect(classifySource("/abs/path.md")).toBe("file");
|
|
23
|
+
});
|
|
24
|
+
it("classifies anything else as inline", () => {
|
|
25
|
+
expect(classifySource("Just be helpful.")).toBe("inline");
|
|
26
|
+
expect(classifySource("foo bar baz")).toBe("inline");
|
|
27
|
+
});
|
|
28
|
+
it("rejects empty strings", () => {
|
|
29
|
+
expect(() => classifySource("")).toThrow(/empty/i);
|
|
30
|
+
expect(() => classifySource(" ")).toThrow(/empty/i);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("sourceZ", () => {
|
|
34
|
+
it("accepts non-empty string", () => {
|
|
35
|
+
expect(sourceZ.parse("hello")).toBe("hello");
|
|
36
|
+
expect(sourceZ.parse("./x.md")).toBe("./x.md");
|
|
37
|
+
expect(sourceZ.parse("https://x/y")).toBe("https://x/y");
|
|
38
|
+
});
|
|
39
|
+
it("rejects empty string", () => {
|
|
40
|
+
expect(() => sourceZ.parse("")).toThrow();
|
|
41
|
+
});
|
|
42
|
+
it("accepts tagged inline form", () => {
|
|
43
|
+
expect(sourceZ.parse({ inline: "text" })).toEqual({ inline: "text" });
|
|
44
|
+
});
|
|
45
|
+
it("accepts tagged file form", () => {
|
|
46
|
+
expect(sourceZ.parse({ file: "./x.md" })).toEqual({ file: "./x.md" });
|
|
47
|
+
});
|
|
48
|
+
it("accepts tagged url form with and without type", () => {
|
|
49
|
+
expect(sourceZ.parse({ url: "https://x" })).toEqual({ url: "https://x" });
|
|
50
|
+
expect(sourceZ.parse({ url: "https://x", type: "fetch" })).toEqual({
|
|
51
|
+
url: "https://x",
|
|
52
|
+
type: "fetch",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
it("rejects objects with multiple tag keys", () => {
|
|
56
|
+
expect(() => sourceZ.parse({ inline: "x", file: "./y" })).toThrow();
|
|
57
|
+
expect(() => sourceZ.parse({ file: "./x", url: "https://y" })).toThrow();
|
|
58
|
+
});
|
|
59
|
+
it("rejects objects with no tag keys", () => {
|
|
60
|
+
expect(() => sourceZ.parse({})).toThrow();
|
|
61
|
+
expect(() => sourceZ.parse({ foo: "bar" })).toThrow();
|
|
62
|
+
});
|
|
63
|
+
it("rejects extra keys on tagged forms", () => {
|
|
64
|
+
expect(() => sourceZ.parse({ inline: "x", extra: true })).toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe("hashContent", () => {
|
|
68
|
+
it("produces stable 16-char hex for same content", () => {
|
|
69
|
+
const a = hashContent("hello world");
|
|
70
|
+
const b = hashContent("hello world");
|
|
71
|
+
expect(a).toBe(b);
|
|
72
|
+
expect(a).toMatch(/^[0-9a-f]{16}$/);
|
|
73
|
+
});
|
|
74
|
+
it("produces different hashes for different content", () => {
|
|
75
|
+
expect(hashContent("a")).not.toBe(hashContent("b"));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe("resolveSource (inline)", () => {
|
|
79
|
+
it("returns the plain string for inline-classified strings", async () => {
|
|
80
|
+
const resolved = await resolveSource("Just do it.", "test.field", baseCtx());
|
|
81
|
+
expect(resolved.content).toBe("Just do it.");
|
|
82
|
+
expect(resolved.kind).toBe("inline");
|
|
83
|
+
expect(resolved.resolver).toBe("inline");
|
|
84
|
+
expect(resolved.origin).toBe("Just do it.");
|
|
85
|
+
expect(resolved.hash).toMatch(/^[0-9a-f]{16}$/);
|
|
86
|
+
expect(resolved.fetchedAt).toBeUndefined();
|
|
87
|
+
expect(resolved.sourcePath).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
it("returns the inline text from tagged {inline} form", async () => {
|
|
90
|
+
const resolved = await resolveSource({ inline: "Tagged body" }, "test.field", baseCtx());
|
|
91
|
+
expect(resolved.content).toBe("Tagged body");
|
|
92
|
+
expect(resolved.kind).toBe("inline");
|
|
93
|
+
expect(resolved.resolver).toBe("inline");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe("resolveSource (file)", () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
rmSync(fileTmp, { recursive: true, force: true });
|
|
99
|
+
mkdirSync(fileTmp, { recursive: true });
|
|
100
|
+
});
|
|
101
|
+
it("reads a file by relative path (cwd-based)", async () => {
|
|
102
|
+
writeFileSync(path.join(fileTmp, "rules.md"), "# Rules\n\nBe kind.");
|
|
103
|
+
const resolved = await resolveSource("./rules.md", "rules[0]", {
|
|
104
|
+
...baseCtx(),
|
|
105
|
+
cwd: fileTmp,
|
|
106
|
+
});
|
|
107
|
+
expect(resolved.content).toBe("# Rules\n\nBe kind.");
|
|
108
|
+
expect(resolved.kind).toBe("file");
|
|
109
|
+
expect(resolved.resolver).toBe("file");
|
|
110
|
+
expect(resolved.sourcePath).toBe(path.resolve(fileTmp, "rules.md"));
|
|
111
|
+
});
|
|
112
|
+
it("reads a file via tagged {file} form", async () => {
|
|
113
|
+
writeFileSync(path.join(fileTmp, "a.txt"), "alpha");
|
|
114
|
+
const resolved = await resolveSource({ file: "./a.txt" }, "x", {
|
|
115
|
+
...baseCtx(),
|
|
116
|
+
cwd: fileTmp,
|
|
117
|
+
});
|
|
118
|
+
expect(resolved.content).toBe("alpha");
|
|
119
|
+
expect(resolved.origin).toEqual({ file: "./a.txt" });
|
|
120
|
+
});
|
|
121
|
+
it("reads an absolute path", async () => {
|
|
122
|
+
const abs = path.join(fileTmp, "abs.md");
|
|
123
|
+
writeFileSync(abs, "absolute body");
|
|
124
|
+
const resolved = await resolveSource(abs, "x", baseCtx());
|
|
125
|
+
expect(resolved.content).toBe("absolute body");
|
|
126
|
+
});
|
|
127
|
+
it("throws SOURCE_FILE_NOT_FOUND with field path on missing file", async () => {
|
|
128
|
+
await expect(resolveSource("./missing.md", "nodes.investigate.instruction", {
|
|
129
|
+
...baseCtx(),
|
|
130
|
+
cwd: fileTmp,
|
|
131
|
+
})).rejects.toThrow(/SOURCE_FILE_NOT_FOUND.*nodes\.investigate\.instruction/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe("resolveSource (fetch)", () => {
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
vi.restoreAllMocks();
|
|
137
|
+
});
|
|
138
|
+
it("fetches URL content via plain string", async () => {
|
|
139
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
|
140
|
+
return new Response("# Shared rules\n\nBe careful.", {
|
|
141
|
+
status: 200,
|
|
142
|
+
headers: { "content-type": "text/markdown" },
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
const resolved = await resolveSource("https://example.com/rules.md", "rules[0]", baseCtx());
|
|
146
|
+
expect(resolved.content).toBe("# Shared rules\n\nBe careful.");
|
|
147
|
+
expect(resolved.kind).toBe("url");
|
|
148
|
+
expect(resolved.resolver).toBe("fetch");
|
|
149
|
+
expect(resolved.fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
150
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
151
|
+
});
|
|
152
|
+
it("fetches via tagged {url} form", async () => {
|
|
153
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("body", { status: 200 }));
|
|
154
|
+
const resolved = await resolveSource({ url: "https://x.test/y", type: "fetch" }, "x", baseCtx());
|
|
155
|
+
expect(resolved.content).toBe("body");
|
|
156
|
+
});
|
|
157
|
+
it("throws SOURCE_URL_HTTP_ERROR on 4xx", async () => {
|
|
158
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("nope", { status: 404 }));
|
|
159
|
+
await expect(resolveSource("https://x.test/missing", "nodes.n.instruction", baseCtx())).rejects.toThrow(/SOURCE_URL_HTTP_ERROR.*404.*nodes\.n\.instruction/);
|
|
160
|
+
});
|
|
161
|
+
it("throws SOURCE_URL_AUTH_REQUIRED on 401", async () => {
|
|
162
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("denied", { status: 401 }));
|
|
163
|
+
await expect(resolveSource("https://x.test/auth", "x", baseCtx())).rejects.toThrow(/SOURCE_URL_AUTH_REQUIRED/);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("fetch auth headers", () => {
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
vi.restoreAllMocks();
|
|
169
|
+
});
|
|
170
|
+
it("sets Authorization from per-host fetch.auth config", async () => {
|
|
171
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("ok", { status: 200 }));
|
|
172
|
+
await resolveSource("https://raw.githubusercontent.com/x/y/main/z.md", "f", {
|
|
173
|
+
...baseCtx(),
|
|
174
|
+
env: { GITHUB_TOKEN: "ghp_abc" },
|
|
175
|
+
authConfig: { "raw.githubusercontent.com": "GITHUB_TOKEN" },
|
|
176
|
+
});
|
|
177
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
178
|
+
expect(init.headers).toMatchObject({
|
|
179
|
+
Authorization: "Bearer ghp_abc",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
it("falls back to SWENY_FETCH_TOKEN when no per-host match", async () => {
|
|
183
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("ok", { status: 200 }));
|
|
184
|
+
await resolveSource("https://other.example/x", "f", {
|
|
185
|
+
...baseCtx(),
|
|
186
|
+
env: { SWENY_FETCH_TOKEN: "global_token" },
|
|
187
|
+
authConfig: { "raw.githubusercontent.com": "GITHUB_TOKEN" },
|
|
188
|
+
});
|
|
189
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
190
|
+
expect(init.headers).toMatchObject({
|
|
191
|
+
Authorization: "Bearer global_token",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
it("omits Authorization when env var is unset", async () => {
|
|
195
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("ok", { status: 200 }));
|
|
196
|
+
await resolveSource("https://raw.githubusercontent.com/x", "f", {
|
|
197
|
+
...baseCtx(),
|
|
198
|
+
env: {},
|
|
199
|
+
authConfig: { "raw.githubusercontent.com": "GITHUB_TOKEN" },
|
|
200
|
+
});
|
|
201
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
202
|
+
const headers = init.headers;
|
|
203
|
+
expect(headers.Authorization).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe("offline mode", () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
vi.restoreAllMocks();
|
|
209
|
+
rmSync(fileTmp, { recursive: true, force: true });
|
|
210
|
+
mkdirSync(fileTmp, { recursive: true });
|
|
211
|
+
});
|
|
212
|
+
it("file resolution still works when offline", async () => {
|
|
213
|
+
writeFileSync(path.join(fileTmp, "local.md"), "hi");
|
|
214
|
+
const resolved = await resolveSource("./local.md", "f", {
|
|
215
|
+
...baseCtx(),
|
|
216
|
+
cwd: fileTmp,
|
|
217
|
+
offline: true,
|
|
218
|
+
});
|
|
219
|
+
expect(resolved.content).toBe("hi");
|
|
220
|
+
});
|
|
221
|
+
it("url resolution fails with SOURCE_OFFLINE_REQUIRES_FETCH when offline", async () => {
|
|
222
|
+
await expect(resolveSource("https://example.com/x", "rules[0]", {
|
|
223
|
+
...baseCtx(),
|
|
224
|
+
offline: true,
|
|
225
|
+
})).rejects.toThrow(/SOURCE_OFFLINE_REQUIRES_FETCH.*https:\/\/example\.com\/x.*rules\[0\]/);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
describe("resolveSources (orchestrator)", () => {
|
|
229
|
+
beforeEach(() => {
|
|
230
|
+
vi.restoreAllMocks();
|
|
231
|
+
rmSync(fileTmp, { recursive: true, force: true });
|
|
232
|
+
mkdirSync(fileTmp, { recursive: true });
|
|
233
|
+
});
|
|
234
|
+
it("resolves a map of Sources and returns a parallel ResolvedSource map", async () => {
|
|
235
|
+
writeFileSync(path.join(fileTmp, "a.md"), "alpha");
|
|
236
|
+
const fetchMock = vi
|
|
237
|
+
.spyOn(globalThis, "fetch")
|
|
238
|
+
.mockImplementation(async () => new Response("beta", { status: 200 }));
|
|
239
|
+
const out = await resolveSources({
|
|
240
|
+
"nodes.n.instruction": "Inline thing.",
|
|
241
|
+
"rules[0]": "./a.md",
|
|
242
|
+
"context[0]": "https://x.test/doc",
|
|
243
|
+
}, { ...baseCtx(), cwd: fileTmp });
|
|
244
|
+
expect(out["nodes.n.instruction"].content).toBe("Inline thing.");
|
|
245
|
+
expect(out["rules[0]"].content).toBe("alpha");
|
|
246
|
+
expect(out["context[0]"].content).toBe("beta");
|
|
247
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
248
|
+
});
|
|
249
|
+
it("caches by canonical key: same URL referenced twice fetches once", async () => {
|
|
250
|
+
const fetchMock = vi
|
|
251
|
+
.spyOn(globalThis, "fetch")
|
|
252
|
+
.mockImplementation(async () => new Response("shared", { status: 200 }));
|
|
253
|
+
const out = await resolveSources({
|
|
254
|
+
"rules[0]": "https://x.test/shared.md",
|
|
255
|
+
"context[0]": "https://x.test/shared.md",
|
|
256
|
+
}, baseCtx());
|
|
257
|
+
expect(out["rules[0]"].content).toBe("shared");
|
|
258
|
+
expect(out["context[0]"].content).toBe("shared");
|
|
259
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
260
|
+
});
|
|
261
|
+
it("reports the first failure's field path", async () => {
|
|
262
|
+
await expect(resolveSources({ "nodes.bad.instruction": "./does-not-exist.md" }, { ...baseCtx(), cwd: fileTmp })).rejects.toThrow(/SOURCE_FILE_NOT_FOUND.*nodes\.bad\.instruction/);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comprehensive validation tests for the unified Source type.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify correctness, completeness, and best practices
|
|
5
|
+
* beyond the unit tests in sources.test.ts. They cover:
|
|
6
|
+
* - Review fix features: retry on 5xx, BOM stripping, SOURCE_INVALID_TYPE, tagged trimming
|
|
7
|
+
* - Edge cases: whitespace, unicode, large content, concurrent resolution
|
|
8
|
+
* - Schema validation: sourceZ boundary cases
|
|
9
|
+
* - Error message quality: field paths, remediation hints
|
|
10
|
+
* - Security: no auth token leakage, URL validation
|
|
11
|
+
*/
|
|
12
|
+
export {};
|