skyloom 1.24.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/dist/cli/main.js +2 -22
  3. package/dist/cli/main.js.map +1 -1
  4. package/dist/cli/tui.d.ts.map +1 -1
  5. package/dist/cli/tui.js +0 -6
  6. package/dist/cli/tui.js.map +1 -1
  7. package/dist/core/diff.d.ts.map +1 -1
  8. package/dist/core/diff.js +0 -1
  9. package/dist/core/diff.js.map +1 -1
  10. package/dist/core/evolve.d.ts.map +1 -1
  11. package/dist/core/evolve.js +0 -9
  12. package/dist/core/evolve.js.map +1 -1
  13. package/dist/core/graph.d.ts +1 -1
  14. package/dist/core/graph.d.ts.map +1 -1
  15. package/dist/core/graph.js +1 -1
  16. package/dist/core/graph.js.map +1 -1
  17. package/dist/core/llm.d.ts +3 -0
  18. package/dist/core/llm.d.ts.map +1 -1
  19. package/dist/core/llm.js +4 -27
  20. package/dist/core/llm.js.map +1 -1
  21. package/dist/core/sandbox.d.ts.map +1 -1
  22. package/dist/core/sandbox.js +0 -2
  23. package/dist/core/sandbox.js.map +1 -1
  24. package/dist/core/schemas.d.ts +1 -1
  25. package/dist/core/schemas.d.ts.map +1 -1
  26. package/dist/core/schemas.js +1 -23
  27. package/dist/core/schemas.js.map +1 -1
  28. package/dist/core/skill.d.ts.map +1 -1
  29. package/dist/core/skill.js +0 -1
  30. package/dist/core/skill.js.map +1 -1
  31. package/dist/gateway/qr.d.ts.map +1 -1
  32. package/dist/gateway/qr.js +0 -1
  33. package/dist/gateway/qr.js.map +1 -1
  34. package/dist/tools/computer.d.ts.map +1 -1
  35. package/dist/tools/computer.js.map +1 -1
  36. package/dist/web/server.d.ts.map +1 -1
  37. package/dist/web/server.js +0 -2
  38. package/dist/web/server.js.map +1 -1
  39. package/eslint.config.js +62 -0
  40. package/package.json +1 -1
  41. package/src/cli/main.ts +3 -22
  42. package/src/cli/tui.ts +0 -2
  43. package/src/core/diff.ts +0 -1
  44. package/src/core/evolve.ts +0 -6
  45. package/src/core/graph.ts +1 -1
  46. package/src/core/llm.ts +4 -33
  47. package/src/core/sandbox.ts +1 -4
  48. package/src/core/schemas.ts +1 -25
  49. package/src/core/skill.ts +0 -1
  50. package/src/gateway/qr.ts +0 -1
  51. package/src/tools/computer.ts +2 -2
  52. package/src/web/server.ts +0 -3
  53. package/tests/factory.test.ts +56 -0
  54. package/tests/pipelines.test.ts +118 -0
@@ -0,0 +1,62 @@
1
+ // ESLint 9 flat config. Replaces the missing config that made `npm run lint` a
2
+ // silent no-op. Scope: src/ TypeScript. We run WITHOUT type-aware linting (no
3
+ // parserOptions.project) so it's fast and doesn't need a second TS program;
4
+ // the strict tsconfig already does the type checking.
5
+ //
6
+ // Philosophy: errors catch real bugs (unused vars, unsafe patterns); the large
7
+ // existing `any` surface and a few stylistic rules are warnings so the gate is
8
+ // honest without forcing a mass rewrite to go green. Tighten over time.
9
+
10
+ const tsParser = require('@typescript-eslint/parser');
11
+ const tsPlugin = require('@typescript-eslint/eslint-plugin');
12
+
13
+ module.exports = [
14
+ {
15
+ ignores: ['dist/**', 'node_modules/**', 'coverage/**', '**/*.d.ts'],
16
+ },
17
+ {
18
+ files: ['src/**/*.ts'],
19
+ languageOptions: {
20
+ parser: tsParser,
21
+ ecmaVersion: 2022,
22
+ sourceType: 'module',
23
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
24
+ globals: {
25
+ process: 'readonly', console: 'readonly', Buffer: 'readonly',
26
+ __dirname: 'readonly', __filename: 'readonly', module: 'writable',
27
+ require: 'readonly', exports: 'writable', global: 'readonly',
28
+ setTimeout: 'readonly', clearTimeout: 'readonly',
29
+ setInterval: 'readonly', clearInterval: 'readonly',
30
+ URL: 'readonly', URLSearchParams: 'readonly', fetch: 'readonly',
31
+ AbortController: 'readonly', AbortSignal: 'readonly',
32
+ FormData: 'readonly', Blob: 'readonly', TextEncoder: 'readonly', TextDecoder: 'readonly',
33
+ performance: 'readonly', NodeJS: 'readonly', BufferEncoding: 'readonly',
34
+ },
35
+ },
36
+ plugins: { '@typescript-eslint': tsPlugin },
37
+ rules: {
38
+ // ── Real-bug errors ──
39
+ 'no-unused-vars': 'off', // superseded by the TS-aware version below
40
+ '@typescript-eslint/no-unused-vars': ['error', {
41
+ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none',
42
+ ignoreRestSiblings: true,
43
+ }],
44
+ 'no-undef': 'error',
45
+ 'no-dupe-keys': 'error',
46
+ 'no-unreachable': 'error',
47
+ 'no-constant-condition': ['error', { checkLoops: false }],
48
+ 'no-fallthrough': 'error',
49
+ 'no-self-assign': 'error',
50
+ 'no-unsafe-negation': 'error',
51
+ 'use-isnan': 'error',
52
+ 'valid-typeof': 'error',
53
+ 'no-cond-assign': ['error', 'except-parens'],
54
+
55
+ // ── Debt warnings (honest, not blocking) ──
56
+ '@typescript-eslint/no-explicit-any': 'warn',
57
+ 'no-empty': ['warn', { allowEmptyCatch: true }],
58
+ 'prefer-const': 'warn',
59
+ 'no-var': 'warn',
60
+ },
61
+ },
62
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skyloom",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "description": "天空织机 Skyloom — 6 weather-themed AI agents: Fog, Rain, Frost, Snow, Dew, Fair",
5
5
  "preferGlobal": true,
6
6
  "type": "commonjs",
package/src/cli/main.ts CHANGED
@@ -10,9 +10,8 @@ import { createSystemContext, orchestrateTask } from "../core/factory";
10
10
  import { loadConfig, USER_CONFIG_DIR } from "../core/config";
11
11
  import { listProviders, modelsFor, providerLabel, validateModel } from "../core/catalog";
12
12
  import { agentTheme } from "../core/theme";
13
- import { classify } from "../core/router";
14
13
  import { InteractiveMode, ModeController } from "./mode";
15
- import { readLine, renderPalette, StreamRenderer, stripMarkdown } from "./tui";
14
+ import { readLine, renderPalette, StreamRenderer } from "./tui";
16
15
  import { loomChat } from "./loom_chat";
17
16
 
18
17
  const MODE = new ModeController();
@@ -73,18 +72,6 @@ function welcome(agent: any) {
73
72
  process.stdout.write(chalk.dim(" /help for commands · /quit to exit\n\n"));
74
73
  }
75
74
 
76
- function statusBar(agent: any, ctx: any): string {
77
- try {
78
- const cu = agent.contextUsage();
79
- const pct = cu.pct || 0;
80
- const bar = pct < 50 ? chalk.green : pct < 80 ? chalk.yellow : chalk.red;
81
- const filled = Math.round(pct / 10);
82
- const ctxBar = `${bar("█".repeat(filled) + "░".repeat(10 - filled))} ${pct}%`;
83
- const cost = formatCost(ctx.llm.getTotalCost());
84
- return chalk.dim(`${ctxBar} · ${cost} · ${cu.model || "?"}`);
85
- } catch { return ""; }
86
- }
87
-
88
75
  function formatCost(c: number): string {
89
76
  if (c >= 1) return chalk.yellow(`$${c.toFixed(2)}`);
90
77
  if (c >= 0.01) return chalk.yellow(`$${c.toFixed(4)}`);
@@ -119,11 +106,6 @@ async function streamResponse(agent: any, input: string): Promise<void> {
119
106
  const header = () => { if (!headerShown) { out.write("\n " + chalk.bold.hex(theme.hex)(`${theme.symbol} ${theme.kanji}`) + chalk.hex(theme.hex)(` ${theme.name}`) + "\n\n"); headerShown = true; } };
120
107
  const endBlock = () => { if (renderer) { renderer.flush(); renderer = null; out.write("\n"); } };
121
108
 
122
- // All content passes through this cleaner before display
123
- const writeClean = (text: string) => {
124
- if (renderer) renderer.write(text);
125
- };
126
-
127
109
  // ── Ctrl-C interrupts this turn (keeps partial output); a 2nd Ctrl-C exits. ──
128
110
  const controller = new AbortController();
129
111
  let interrupted = false;
@@ -374,7 +356,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
374
356
 
375
357
  // Wire up security approval — prompt user for HIGH/CRITICAL operations
376
358
  try {
377
- const { getSecurity, DangerLevel, PERMISSION_MODE_ALIASES } = require("../core/security");
359
+ const { getSecurity, PERMISSION_MODE_ALIASES } = require("../core/security");
378
360
  const sec = getSecurity();
379
361
  // Honor a configured permission mode (config.yaml cli.approvalMode), mapped
380
362
  // through the same aliases as /perm.
@@ -405,7 +387,6 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
405
387
  return; // loomChat exits the process itself
406
388
  }
407
389
 
408
- // eslint-disable-next-line prefer-const
409
390
  let currentAgent = agent; // mutable for agent switching
410
391
  let lastSessions: any[] = []; // index→session map for /resume <n>
411
392
  welcome(agent);
@@ -599,7 +580,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
599
580
  continue;
600
581
  }
601
582
  if (cmdL === "/models" || cmdL.startsWith("/models ")) {
602
- const { listProviders, modelsFor, providerLabel, allModels } = require("../core/catalog");
583
+ const { listProviders, modelsFor, providerLabel } = require("../core/catalog");
603
584
  const args = inp.split(/\s+/).slice(1);
604
585
  const filter = args[0]?.toLowerCase() || "";
605
586
  process.stdout.write(chalk.bold("\n ✦ 模型目录 · Model Catalog\n"));
package/src/cli/tui.ts CHANGED
@@ -14,8 +14,6 @@ import chalk from "chalk";
14
14
  import { agentTheme, PALETTE } from "../core/theme";
15
15
  import { registry } from "../core/commands";
16
16
 
17
- const TUI_VERSION = (() => { try { return require("../../package.json").version; } catch { return ""; } })();
18
-
19
17
  export interface TUIContext {
20
18
  agent: any;
21
19
  agents: Map<string, any>;
package/src/core/diff.ts CHANGED
@@ -60,7 +60,6 @@ export function unifiedDiff(oldStr: string, newStr: string, opts: DiffOptions =
60
60
  // Context window bounds.
61
61
  const ctxStart = Math.max(0, pre - context);
62
62
  const oldCtxAfterStart = oldLines.length - suf;
63
- const newCtxAfterStart = newLines.length - suf;
64
63
  const oldCtxAfter = oldLines.slice(oldCtxAfterStart, oldCtxAfterStart + context);
65
64
  const leading = oldLines.slice(ctxStart, pre);
66
65
 
@@ -121,12 +121,6 @@ export function analyzeFailures(
121
121
  }
122
122
  }
123
123
 
124
- // General rule: if failure rate > 20%, suggest self-review
125
- const recentExperiences = recent.filter(e => {
126
- try { return new Date(e.lastSeen).getTime() > Date.now() - 3 * 86400000; }
127
- catch { return false; }
128
- });
129
-
130
124
  // Deduplicate suggestions
131
125
  const seen = new Set<string>();
132
126
  const uniqueDiffs = suggestedDiffs.filter(d => {
package/src/core/graph.ts CHANGED
@@ -140,7 +140,7 @@ const RELATION_PATTERNS: Array<[RegExp, string]> = [
140
140
  [/(\w+) (?:file|path|文件|路径) (?:在|为|at) (.+?)(?:[。,,.\n]|$)/gi, "located_at"],
141
141
  ];
142
142
 
143
- export function extractFacts(text: string, agent: string): Array<[string, string, string]> {
143
+ export function extractFacts(text: string, _agent: string): Array<[string, string, string]> {
144
144
  const facts: Array<[string, string, string]> = [];
145
145
  for (const [pattern, pred] of RELATION_PATTERNS) {
146
146
  let match;
package/src/core/llm.ts CHANGED
@@ -355,37 +355,6 @@ const FALLBACK_CHAINS: Map<string, string[]> = new Map([
355
355
  /**
356
356
  * HTTP status codes that are considered transient errors (worth retrying).
357
357
  */
358
- const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
359
-
360
- /**
361
- * Check if an exception is worth retrying.
362
- */
363
- function isTransientError(err: unknown): boolean {
364
- if (err instanceof Error) {
365
- const status =
366
- (err as any).status_code || (err as any).http_status || 0;
367
- if (status && RETRYABLE_STATUSES.has(status)) {
368
- return true;
369
- }
370
-
371
- if (err.name === "TimeoutError") {
372
- return true;
373
- }
374
-
375
- const errName = err.constructor.name.toLowerCase();
376
- return [
377
- "ratelimiterror",
378
- "apitimeouterror",
379
- "apiconnectionerror",
380
- "serviceunavailableerror",
381
- "internalservererror",
382
- "timeout",
383
- ].includes(errName);
384
- }
385
-
386
- return false;
387
- }
388
-
389
358
  /**
390
359
  * Estimate cost for LLM API call.
391
360
  */
@@ -704,7 +673,7 @@ export class LLMClient {
704
673
  messages: Record<string, unknown>[],
705
674
  agentName?: string,
706
675
  tools?: string[],
707
- stream: boolean = false,
676
+ _stream: boolean = false,
708
677
  overrides?: Record<string, unknown>
709
678
  ): Promise<LLMResponse> {
710
679
  const temperature = (overrides?.temperature as number) ?? 0.7;
@@ -867,7 +836,9 @@ export class LLMClient {
867
836
  private async *callOpenAIStream(
868
837
  m: string, messages: Record<string, unknown>[], tools?: string[], temp?: number, maxTok?: number, signal?: AbortSignal, agentName?: string
869
838
  ): AsyncGenerator<StreamEvent> {
870
- const apiKey = this.getApiKey(m);
839
+ // Honor a per-agent API key override (agents.<name>.api_key) on the
840
+ // streaming path too — previously only the non-streaming path passed it.
841
+ const apiKey = this.getApiKey(m, agentName);
871
842
  const baseUrl = this.getBaseUrl(m);
872
843
  const body: Record<string, unknown> = {
873
844
  model: m, messages, temperature: temp ?? 0.7, max_tokens: maxTok ?? 4096,
@@ -6,15 +6,12 @@
6
6
  * limits, and dangerous command detection BEFORE execution.
7
7
  */
8
8
 
9
- import { execSync, exec } from "child_process";
9
+ import { execSync } from "child_process";
10
10
  import * as fs from "fs";
11
11
  import * as path from "path";
12
12
  import * as os from "os";
13
- import { getLogger } from "./logger";
14
13
  import { REDLINE_PATTERNS, REDLINE_COMMANDS } from "./security";
15
14
 
16
- const log = getLogger("sandbox");
17
-
18
15
  /* ═══════════════════════════════════════
19
16
  Configuration
20
17
  ═══════════════════════════════════════ */
@@ -78,30 +78,6 @@ export interface MessageSchema {
78
78
  tool_call_id?: string;
79
79
  }
80
80
 
81
- /**
82
- * Coerce a value to a target type with best-effort conversion
83
- */
84
- function coerceType(value: unknown, targetType: string): unknown {
85
- if (value === null || value === undefined) {
86
- return value;
87
- }
88
-
89
- switch (targetType) {
90
- case "string":
91
- return String(value);
92
- case "number":
93
- return Number(value);
94
- case "boolean":
95
- return Boolean(value);
96
- case "array":
97
- return Array.isArray(value) ? value : [];
98
- case "object":
99
- return typeof value === "object" ? value : {};
100
- default:
101
- return value;
102
- }
103
- }
104
-
105
81
  /**
106
82
  * Extract JSON object/array from a potentially malformed string
107
83
  */
@@ -170,7 +146,7 @@ function repairJSON(text: string): string {
170
146
  */
171
147
  export function parseSchema<T extends Record<string, unknown>>(
172
148
  raw: string,
173
- schemaType?: new () => T
149
+ _schemaType?: new () => T
174
150
  ): T {
175
151
  if (!raw || !raw.trim()) {
176
152
  throw new SchemaValidationError("empty response", raw);
package/src/core/skill.ts CHANGED
@@ -245,7 +245,6 @@ function extractSkillHead(body: string, maxChars: number): string {
245
245
  // Patterns for auto-deriving triggers
246
246
  const TRIGGER_QUOTED = /["'"'""]([^"'""\n]{1,40})["'""']/g;
247
247
  const TRIGGER_EXT = /(?<![A-Za-z0-9])\.[A-Za-z0-9]{2,6}\b/g;
248
- const TRIGGER_STRIP = " \t,.;:!?,。、;:!?、。";
249
248
 
250
249
  /**
251
250
  * Pull candidate trigger phrases out of a skill description.
package/src/gateway/qr.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  */
6
6
 
7
7
  // qrcode-terminal ships no types; declare the tiny surface we use.
8
- // eslint-disable-next-line @typescript-eslint/no-require-imports
9
8
  const qrcode: { generate: (text: string, opts: { small?: boolean }, cb: (s: string) => void) => void } =
10
9
  require('qrcode-terminal');
11
10
 
@@ -5,11 +5,11 @@
5
5
  * processes and services, and install/uninstall software.
6
6
  */
7
7
 
8
- import { execSync, execFileSync, spawn } from 'child_process';
8
+ import { execSync, execFileSync } from 'child_process';
9
9
  import * as os from 'os';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
- import type { ToolRegistry, ToolDefinition } from '../core/tool';
12
+ import type { ToolRegistry } from '../core/tool';
13
13
 
14
14
  const MAX_OUT = 8000;
15
15
 
package/src/web/server.ts CHANGED
@@ -8,11 +8,8 @@
8
8
 
9
9
  import { createServer, IncomingMessage, ServerResponse } from "http";
10
10
  import { createSystemContext } from "../core/factory";
11
- import { getLogger } from "../core/logger";
12
11
  import { renderInkWashUI, SKYLOOM_FAVICON_SVG } from "./ui";
13
12
 
14
- const log = getLogger("web-server");
15
-
16
13
  /* ──────────────────────────────────────────────
17
14
  Server
18
15
  ────────────────────────────────────────────── */
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { SystemContext, TaskExecutionResult } from "../src/core/factory";
3
+ import { MessageBus } from "../src/core/bus";
4
+
5
+ describe("factory · TaskExecutionResult", () => {
6
+ it("holds the provided fields", () => {
7
+ const r = new TaskExecutionResult({ id: "t1", agent: "fog", description: "do x", success: true, content: "done" });
8
+ expect(r).toMatchObject({ id: "t1", agent: "fog", success: true, content: "done" });
9
+ });
10
+ });
11
+
12
+ describe("factory · SystemContext", () => {
13
+ function ctx(overrides: Partial<ConstructorParameters<typeof SystemContext>[0]> = {}) {
14
+ return new SystemContext({
15
+ config: { agents: {} } as any,
16
+ bus: new MessageBus(),
17
+ llm: {} as any,
18
+ agentMap: new Map(),
19
+ toolRegistry: {} as any,
20
+ ...overrides,
21
+ });
22
+ }
23
+
24
+ it("stores constructor options with sensible defaults", () => {
25
+ const c = ctx({ workspacePath: "/ws" });
26
+ expect(c.workspacePath).toBe("/ws");
27
+ expect(c.mcp).toBeNull();
28
+ expect(c.mcpStatus).toEqual([]);
29
+ expect(c.agentMap.size).toBe(0);
30
+ });
31
+
32
+ it("initAll fires the plugin init hook before agents come up", async () => {
33
+ const emit = vi.fn().mockResolvedValue(undefined);
34
+ const c = ctx({ plugins: { emit } as any });
35
+ await c.initAll();
36
+ expect(emit).toHaveBeenCalledWith("init", expect.objectContaining({ config: expect.anything() }));
37
+ });
38
+
39
+ it("closeAll closes every agent and tolerates a missing mcp", async () => {
40
+ const close = vi.fn().mockResolvedValue(undefined);
41
+ const agents = new Map<string, any>([
42
+ ["fog", { close }],
43
+ ["rain", { close }],
44
+ ]);
45
+ const c = ctx({ agentMap: agents as any });
46
+ await c.closeAll();
47
+ expect(close).toHaveBeenCalledTimes(2);
48
+ });
49
+
50
+ it("closeAll also closes mcp when present", async () => {
51
+ const closeAll = vi.fn().mockResolvedValue(undefined);
52
+ const c = ctx({ mcp: { closeAll } as any });
53
+ await c.closeAll();
54
+ expect(closeAll).toHaveBeenCalled();
55
+ });
56
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ matchPipeline,
4
+ matchAllPipelines,
5
+ buildTasksFromPipeline,
6
+ listPipelines,
7
+ getPipelineByName,
8
+ validateDAG,
9
+ topologicalSort,
10
+ type Task,
11
+ } from "../src/core/pipelines";
12
+
13
+ function task(id: string, dependsOn: string[] = []): Task {
14
+ return { id, description: id, assignedTo: "fog", parentId: dependsOn[0] ?? null, dependsOn };
15
+ }
16
+
17
+ describe("pipelines · matching", () => {
18
+ it("matches a code-review goal to the code_review pipeline", () => {
19
+ const p = matchPipeline("帮我审查代码");
20
+ expect(p?.name).toBe("code_review");
21
+ });
22
+
23
+ it("matches an English review goal", () => {
24
+ expect(matchPipeline("please review my code")?.name).toBe("code_review");
25
+ });
26
+
27
+ it("returns null for an unmatched / empty goal", () => {
28
+ expect(matchPipeline("今天天气怎么样")).toBeNull();
29
+ expect(matchPipeline("")).toBeNull();
30
+ });
31
+
32
+ it("matchAllPipelines returns an array (possibly empty)", () => {
33
+ expect(Array.isArray(matchAllPipelines("审查代码"))).toBe(true);
34
+ expect(matchAllPipelines("xyzzy nonsense")).toEqual([]);
35
+ });
36
+ });
37
+
38
+ describe("pipelines · introspection", () => {
39
+ it("lists pipelines with names, triggers and steps", () => {
40
+ const list = listPipelines();
41
+ expect(list.length).toBeGreaterThan(0);
42
+ for (const p of list) {
43
+ expect(typeof p.name).toBe("string");
44
+ expect(Array.isArray(p.triggers)).toBe(true);
45
+ expect(Array.isArray(p.steps)).toBe(true);
46
+ }
47
+ });
48
+
49
+ it("getPipelineByName round-trips a listed name; unknown → null", () => {
50
+ const name = listPipelines()[0].name as string;
51
+ expect(getPipelineByName(name)?.name).toBe(name);
52
+ expect(getPipelineByName("___nope___")).toBeNull();
53
+ });
54
+ });
55
+
56
+ describe("pipelines · materialization", () => {
57
+ it("builds tasks from a pipeline, substituting {goal}", () => {
58
+ const p = getPipelineByName("code_review")!;
59
+ const tasks = buildTasksFromPipeline(p, "登录模块");
60
+ expect(tasks.length).toBe(p.steps.length);
61
+ expect(tasks[0].description).toContain("登录模块");
62
+ expect(tasks[0].metadata?.goal).toBe("登录模块");
63
+ expect(tasks[0].metadata?.pipeline).toBe("code_review");
64
+ });
65
+
66
+ it("a materialized pipeline is a valid DAG", () => {
67
+ for (const meta of listPipelines()) {
68
+ const p = getPipelineByName(meta.name as string)!;
69
+ const tasks = buildTasksFromPipeline(p, "x");
70
+ const v = validateDAG(tasks);
71
+ expect(v.valid, `${meta.name}: ${v.errors.join("; ")}`).toBe(true);
72
+ }
73
+ });
74
+ });
75
+
76
+ describe("pipelines · validateDAG", () => {
77
+ it("accepts a linear chain", () => {
78
+ const v = validateDAG([task("1"), task("2", ["1"]), task("3", ["2"])]);
79
+ expect(v.valid).toBe(true);
80
+ expect(v.errors).toEqual([]);
81
+ });
82
+
83
+ it("flags a missing dependency", () => {
84
+ const v = validateDAG([task("2", ["1"])]); // 1 doesn't exist
85
+ expect(v.valid).toBe(false);
86
+ expect(v.errors.join(" ")).toMatch(/non-existent/);
87
+ });
88
+
89
+ it("detects a cycle", () => {
90
+ const v = validateDAG([task("a", ["b"]), task("b", ["a"])]);
91
+ expect(v.valid).toBe(false);
92
+ expect(v.errors.join(" ")).toMatch(/[Cc]ycle/);
93
+ });
94
+ });
95
+
96
+ describe("pipelines · topologicalSort", () => {
97
+ it("orders dependencies before dependents", () => {
98
+ const sorted = topologicalSort([task("3", ["2"]), task("1"), task("2", ["1"])]);
99
+ const order = sorted.map((t) => t.id);
100
+ expect(order.indexOf("1")).toBeLessThan(order.indexOf("2"));
101
+ expect(order.indexOf("2")).toBeLessThan(order.indexOf("3"));
102
+ });
103
+
104
+ it("handles independent tasks (all in-degree 0)", () => {
105
+ const sorted = topologicalSort([task("a"), task("b"), task("c")]);
106
+ expect(sorted.map((t) => t.id).sort()).toEqual(["a", "b", "c"]);
107
+ });
108
+
109
+ it("a diamond DAG keeps the root first and the join last", () => {
110
+ // a → b, a → c, b → d, c → d
111
+ const sorted = topologicalSort([
112
+ task("d", ["b", "c"]), task("b", ["a"]), task("c", ["a"]), task("a"),
113
+ ]);
114
+ const order = sorted.map((t) => t.id);
115
+ expect(order[0]).toBe("a");
116
+ expect(order[order.length - 1]).toBe("d");
117
+ });
118
+ });