mocode-ai 0.7.0 → 0.7.1

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.
@@ -46,7 +46,33 @@ const _projectSkillEnabledAtBoot = process.env.MOCODE_PROJECT_SKILL === 'true';
46
46
  const _projectSkillTools = _projectSkillEnabledAtBoot
47
47
  ? [projectSkillUpdateTool]
48
48
  : [];
49
- export const builtinTools = [
49
+ const pathResource = (args) => typeof args.path === 'string' && args.path ? [`file:${args.path}`] : ['workspace'];
50
+ const workspaceResource = () => ['workspace'];
51
+ const memoryResource = () => ['memory-store'];
52
+ const CAPABILITIES = {
53
+ read_file: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: pathResource },
54
+ write_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource },
55
+ edit_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource },
56
+ run_command: { effect: 'process', concurrency: 'serial', retry: 'never', resources: workspaceResource, supportsAbort: true },
57
+ glob: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: workspaceResource },
58
+ grep: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: workspaceResource },
59
+ codegraph: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: workspaceResource, supportsAbort: true },
60
+ web_search: { effect: 'network', concurrency: 'parallel', retry: 'safe', supportsAbort: true },
61
+ web_fetch: { effect: 'network', concurrency: 'parallel', retry: 'safe', supportsAbort: true },
62
+ use_skill: { effect: 'read', concurrency: 'serial', retry: 'safe' },
63
+ ask_human: { effect: 'read', concurrency: 'serial', retry: 'never' },
64
+ switch_mode: { effect: 'write', concurrency: 'serial', retry: 'never', resources: () => ['agent-mode'] },
65
+ drop_context: { effect: 'write', concurrency: 'serial', retry: 'never', resources: () => ['conversation-context'] },
66
+ memory_save: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
67
+ memory_search: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
68
+ memory_list: { effect: 'read', concurrency: 'serial', retry: 'safe', resources: memoryResource },
69
+ memory_update: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
70
+ memory_forget: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
71
+ project_skill_update: { effect: 'write', concurrency: 'serial', retry: 'never', resources: workspaceResource },
72
+ // 子 Agent 共享主工作区;在隔离 workspace / write-set 锁实现前必须串行。
73
+ task: { effect: 'write', concurrency: 'serial', retry: 'never', resources: workspaceResource, supportsAbort: true },
74
+ };
75
+ const rawBuiltinTools = [
50
76
  readFileTool,
51
77
  writeFileTool,
52
78
  editFileTool,
@@ -58,9 +84,14 @@ export const builtinTools = [
58
84
  webFetchTool,
59
85
  useSkillTool,
60
86
  askHumanTool,
61
- switchModeTool, // plan↔auto 自切(两模式都可见,不进 PLAN_DISABLED_TOOLS;副作用控制工具→串行分支)
62
- dropContextTool, // 运行中剔除无关 tool 结果(上下文管理,无副作用;两模式都可见,串行分支)
87
+ switchModeTool,
88
+ dropContextTool,
63
89
  ..._memoryTools,
64
90
  ..._projectSkillTools,
65
- taskTool, // 派生子 agent(独立 history + 可受限工具集);plan 模式禁用(见 PLAN_DISABLED_TOOLS)
91
+ taskTool,
66
92
  ];
93
+ /** 所有内置工具均携带显式能力;新增工具遗漏声明时 registry 会保守串行。 */
94
+ export const builtinTools = rawBuiltinTools.map((tool) => ({
95
+ ...tool,
96
+ capabilities: CAPABILITIES[tool.name],
97
+ }));
@@ -1,6 +1,6 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { MAX_OUTPUT } from '../constants.js';
3
- import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.js';
3
+ import { getSandboxRoot, filterEnv, isCommandDenied, jailResolve } from '../../sandbox/index.js';
4
4
  import { t } from '../../i18n/index.js';
5
5
  const OUTPUT_HEAD_LIMIT = Math.floor(MAX_OUTPUT * 0.4);
6
6
  const OUTPUT_TAIL_LIMIT = MAX_OUTPUT - OUTPUT_HEAD_LIMIT;
@@ -25,6 +25,119 @@ class BoundedCommandOutput {
25
25
  return `${this.head}\n${t('command.outputTruncated', { count: removed })}\n${this.tail}`;
26
26
  }
27
27
  }
28
+ /** Execute a command with the same sandbox, output cap and cancellation semantics as run_command. */
29
+ export async function runCommandRaw(command, timeout = 120000, signal, cwd) {
30
+ const startedAt = Date.now();
31
+ const deny = isCommandDenied(command);
32
+ if (deny) {
33
+ return { status: 'denied', exitCode: null, output: `错误:${deny}`, durationMs: 0 };
34
+ }
35
+ let executionCwd = getSandboxRoot() ?? process.cwd();
36
+ if (cwd) {
37
+ try {
38
+ executionCwd = jailResolve(cwd);
39
+ }
40
+ catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ return { status: 'denied', exitCode: null, output: `错误:${message}`, durationMs: 0 };
43
+ }
44
+ }
45
+ return new Promise((done) => {
46
+ const isWin = process.platform === 'win32';
47
+ const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/d', '/s', '/c', command] : ['-c', command], {
48
+ cwd: executionCwd,
49
+ env: filterEnv(process.env),
50
+ // Without this, Node re-quotes cmd.exe arguments and `node -e "..."` can become
51
+ // a string literal that exits 0, causing false-positive validation on Windows.
52
+ windowsVerbatimArguments: isWin,
53
+ });
54
+ const output = new BoundedCommandOutput();
55
+ let finished = false;
56
+ let timer;
57
+ const killTree = () => {
58
+ try {
59
+ if (isWin) {
60
+ if (child.pid != null) {
61
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
62
+ }
63
+ }
64
+ else {
65
+ child.kill('SIGTERM');
66
+ }
67
+ }
68
+ catch {
69
+ // Process already exited or best-effort termination failed.
70
+ }
71
+ };
72
+ const finish = (result) => {
73
+ if (finished)
74
+ return;
75
+ finished = true;
76
+ if (timer)
77
+ clearTimeout(timer);
78
+ signal?.removeEventListener('abort', onAbort);
79
+ done({ ...result, durationMs: Date.now() - startedAt });
80
+ };
81
+ const onAbort = () => {
82
+ killTree();
83
+ finish({ status: 'aborted', exitCode: null, output: output.render().trim() });
84
+ };
85
+ const onChunk = (chunk) => output.append(chunk.toString('utf8'));
86
+ child.stdout.on('data', onChunk);
87
+ child.stderr.on('data', onChunk);
88
+ child.on('error', (error) => {
89
+ finish({ status: 'spawn_error', exitCode: null, output: error.message });
90
+ });
91
+ child.on('close', (code) => {
92
+ finish({
93
+ status: code === 0 ? 'passed' : 'failed',
94
+ exitCode: code,
95
+ output: output.render().trim(),
96
+ });
97
+ });
98
+ timer = setTimeout(() => {
99
+ killTree();
100
+ finish({ status: 'timed_out', exitCode: null, output: output.render().trim() });
101
+ }, timeout);
102
+ if (signal) {
103
+ if (signal.aborted)
104
+ onAbort();
105
+ else
106
+ signal.addEventListener('abort', onAbort, { once: true });
107
+ }
108
+ });
109
+ }
110
+ /** Preserve the public run_command text protocol while exposing structured status internally. */
111
+ export function formatCommandResult(result) {
112
+ const output = result.output.trim();
113
+ if (result.status === 'denied')
114
+ return result.output;
115
+ if (result.status === 'aborted')
116
+ return `${t('command.interrupted')}\n${output}`;
117
+ if (result.status === 'timed_out')
118
+ return `${t('command.timedOut')}\n${output}`;
119
+ if (result.status === 'spawn_error')
120
+ return t('command.executionFailed', { message: result.output });
121
+ return `${t('command.exitCode', { code: result.exitCode ?? 'null' })}\n${output || t('toolSummary.noOutput')}`;
122
+ }
123
+ /** Convert the raw process status into the common structured tool contract. */
124
+ function commandOutcome(result) {
125
+ const output = formatCommandResult(result);
126
+ switch (result.status) {
127
+ case 'passed':
128
+ return { status: 'success', code: 'OK', retryable: false, output, durationMs: result.durationMs };
129
+ case 'aborted':
130
+ return { status: 'aborted', code: 'ABORTED', retryable: false, output, durationMs: result.durationMs };
131
+ case 'denied':
132
+ return { status: 'denied', code: 'SANDBOX_DENIED', retryable: false, output, durationMs: result.durationMs };
133
+ case 'timed_out':
134
+ return { status: 'error', code: 'TIMEOUT', retryable: false, output, durationMs: result.durationMs };
135
+ case 'failed':
136
+ return { status: 'error', code: 'PROCESS_FAILED', retryable: false, output, durationMs: result.durationMs };
137
+ case 'spawn_error':
138
+ return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output, durationMs: result.durationMs };
139
+ }
140
+ }
28
141
  // ---------- run_command ----------
29
142
  export const runCommandTool = {
30
143
  name: 'run_command',
@@ -41,70 +154,6 @@ export const runCommandTool = {
41
154
  async execute(args, ctx) {
42
155
  const command = String(args.command);
43
156
  const timeout = Number(args.timeout ?? 120000);
44
- // 沙箱 best-effort:灾难性文件操作 denylist(非安全边界,只挡误操作;真隔离需 OS jailer)
45
- const deny = isCommandDenied(command);
46
- if (deny)
47
- return `错误:${deny}`;
48
- return new Promise((done) => {
49
- const isWin = process.platform === 'win32';
50
- // 沙箱 best-effort:cwd 钉死 sandbox root(相对路径写落在牢内)+ env 脱敏(剥 *KEY/*TOKEN 等,防 LLM_API_KEY 泄子进程)
51
- const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: getSandboxRoot() ?? process.cwd(), env: filterEnv(process.env) });
52
- const output = new BoundedCommandOutput();
53
- let finished = false;
54
- let timer;
55
- // 杀整棵进程树。child.kill() 在 Windows 只杀 cmd.exe、npm 等子进程会孤儿继续跑(占锁、污染下一步),
56
- // 故 Win 用 taskkill /T /F 树杀;Unix child.kill('SIGTERM')(bash -c 通常转发给前台子进程,best-effort)。
57
- const killTree = () => {
58
- try {
59
- if (isWin) {
60
- if (child.pid != null) {
61
- spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
62
- stdio: 'ignore',
63
- });
64
- }
65
- }
66
- else {
67
- child.kill('SIGTERM');
68
- }
69
- }
70
- catch {
71
- // 进程已退出 / kill 失败:忽略(close 事件会兜底 finish)
72
- }
73
- };
74
- // abort(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 杀子进程树 + 返[已中断]
75
- const onAbort = () => {
76
- killTree();
77
- finish(`${t('command.interrupted')}\n${output.render().trim()}`);
78
- };
79
- const finish = (s) => {
80
- if (finished)
81
- return;
82
- finished = true;
83
- clearTimeout(timer);
84
- ctx?.signal?.removeEventListener('abort', onAbort);
85
- done(s);
86
- };
87
- const onChunk = (chunk) => {
88
- output.append(chunk.toString('utf8'));
89
- };
90
- child.stdout.on('data', onChunk);
91
- child.stderr.on('data', onChunk);
92
- child.on('error', (e) => finish(t('command.executionFailed', { message: e.message })));
93
- child.on('close', (code) => {
94
- const result = output.render().trim();
95
- finish(`${t('command.exitCode', { code: code ?? 'null' })}\n${result || t('toolSummary.noOutput')}`);
96
- });
97
- timer = setTimeout(() => {
98
- killTree();
99
- finish(`${t('command.timedOut')}\n${output.render().trim()}`);
100
- }, timeout);
101
- // 外部 abort signal:已 aborted 即时杀(防御;agent 循环顶检查通常会先拦),否则挂监听
102
- if (ctx?.signal) {
103
- if (ctx.signal.aborted)
104
- onAbort();
105
- else
106
- ctx.signal.addEventListener('abort', onAbort, { once: true });
107
- }
108
- });
157
+ return commandOutcome(await runCommandRaw(command, timeout, ctx?.signal));
109
158
  },
110
159
  };
@@ -1,12 +1,13 @@
1
1
  import { spawnAgent } from '../../agent/spawn.js';
2
2
  import { MAX_OUTPUT } from '../constants.js';
3
3
  import { t } from '../../i18n/index.js';
4
+ import { isSubAgentEnabled } from '../../config/index.js';
4
5
  // ---------- task ----------
5
6
  // 派生子 agent 执行独立子任务。子 agent 有独立 history(不污染主对话),
6
7
  // 可受限工具子集 + 低步数上限,最终摘要回灌主 history 供主 agent 继续。
7
8
  //
8
- // 适用:并行探查多个文件 / 分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
9
- // agent 中间过程不写主屏,只返回最终摘要;主 agent 据摘要决定下一步。
9
+ // 适用:分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
10
+ // 多个 task 在共享工作区期间由 capability scheduler 串行执行;隔离 workspace 落地后再开放并行写。
10
11
  export const taskTool = {
11
12
  name: 'task',
12
13
  risk: 'dangerous',
@@ -35,6 +36,8 @@ export const taskTool = {
35
36
  required: ['prompt'],
36
37
  },
37
38
  async execute(args, ctx) {
39
+ if (!isSubAgentEnabled())
40
+ return t('task.disabled');
38
41
  const prompt = String(args.prompt ?? '');
39
42
  if (!prompt)
40
43
  return t('task.missingPrompt');
@@ -1,5 +1,5 @@
1
1
  /** 工具共享的截断 / 上限 / 忽略规则。 */
2
- import { isMemoryEnabled } from '../config/index.js';
2
+ import { isMemoryEnabled, isSubAgentEnabled } from '../config/index.js';
3
3
  export const MAX_FILE_LINES = 2000;
4
4
  export const MAX_OUTPUT = 20000;
5
5
  export const MAX_RESULTS = 100;
@@ -54,3 +54,7 @@ export function getPlanDisabledTools() {
54
54
  next.delete('memory_forget');
55
55
  return next;
56
56
  }
57
+ /** auto/plan 共用的运行时功能开关防线;关闭时即使模型幻觉调用也不得执行。 */
58
+ export function getRuntimeDisabledTools() {
59
+ return isSubAgentEnabled() ? new Set() : new Set(['task']);
60
+ }
@@ -1,7 +1,8 @@
1
1
  import { builtinTools } from './builtins/index.js';
2
- import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, } from '../rollback/index.js';
2
+ import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, getCurrentTurnMutationState, } from '../rollback/index.js';
3
3
  import { enforceSandbox } from '../sandbox/index.js';
4
4
  import { t } from '../i18n/index.js';
5
+ import { isToolErrorOutput } from './result.js';
5
6
  /**
6
7
  * 可扩展工具注册表。数组实例始终稳定,使已经持有 tools 引用的 agent/LLM 能看到运行时新增工具。
7
8
  * 扩展按 source 替换,MCP 重连或配置刷新不会累积旧工具。
@@ -33,40 +34,103 @@ function rebuildTools() {
33
34
  }
34
35
  tools.splice(0, tools.length, ...next);
35
36
  }
37
+ const DEFAULT_CAPABILITIES = Object.freeze({
38
+ effect: 'unknown',
39
+ concurrency: 'serial',
40
+ retry: 'never',
41
+ });
42
+ export function findTool(name) {
43
+ return tools.find((tool) => tool.name === name);
44
+ }
45
+ /** 缺少声明或找不到工具时返回保守能力,绝不把未知扩展并发执行。 */
46
+ export function getToolCapabilities(toolOrName) {
47
+ const tool = typeof toolOrName === 'string' ? findTool(toolOrName) : toolOrName;
48
+ return tool?.capabilities ?? DEFAULT_CAPABILITIES;
49
+ }
50
+ export function getToolResourceKeys(toolOrName, args) {
51
+ const capabilities = getToolCapabilities(toolOrName);
52
+ try {
53
+ return capabilities.resources?.(args) ?? [];
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ /** resource-locked write 是可生成文件 diff/按路径记 rollback 的文件 mutation。 */
60
+ export function isFileMutationTool(name) {
61
+ const capabilities = getToolCapabilities(name);
62
+ return capabilities.effect === 'write' && capabilities.concurrency === 'resource-locked';
63
+ }
64
+ function isStructuredOutcome(value) {
65
+ return typeof value === 'object' && value !== null &&
66
+ typeof value.status === 'string' && typeof value.code === 'string' &&
67
+ typeof value.retryable === 'boolean' && typeof value.output === 'string';
68
+ }
69
+ function normalizeOutcome(value, capabilities, durationMs, changedFiles) {
70
+ if (isStructuredOutcome(value)) {
71
+ return {
72
+ ...value,
73
+ durationMs: value.durationMs ?? durationMs,
74
+ changedFiles: value.changedFiles ?? changedFiles,
75
+ };
76
+ }
77
+ const failed = isToolErrorOutput(value);
78
+ return {
79
+ status: failed ? 'error' : 'success',
80
+ code: failed ? 'EXECUTION_ERROR' : 'OK',
81
+ retryable: failed && capabilities.retry !== 'never',
82
+ output: value,
83
+ changedFiles,
84
+ durationMs,
85
+ };
86
+ }
87
+ function terminalOutcome(status, code, output, startedAt, changedFiles = []) {
88
+ return {
89
+ status,
90
+ code,
91
+ retryable: false,
92
+ output,
93
+ changedFiles,
94
+ durationMs: Date.now() - startedAt,
95
+ };
96
+ }
36
97
  /**
37
- * 按名调度工具,统一 try/catch + JSON 解析,返回字符串而非抛错。
38
- * signal 透传给 tool.execute(经 ctx):长任务工具(run_command/web_fetch)abort 即时取消,
39
- * 让用户 Ctrl+C 能跟手中断工具执行(而非等命令跑完 / 超时)。
40
- * opts.dropContext:上下文剔除回调(drop_context 工具用),透传给 tool.execute 经 ctx。
98
+ * 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
99
+ * 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
41
100
  */
42
- export async function executeTool(name, argsRaw, signal, opts) {
43
- const tool = tools.find((t) => t.name === name);
44
- if (!tool)
45
- return t('toolError.unknown', { name });
101
+ export async function executeToolOutcome(name, argsRaw, signal, opts) {
102
+ const startedAt = Date.now();
103
+ if (signal?.aborted) {
104
+ return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
105
+ }
106
+ const tool = findTool(name);
107
+ if (!tool) {
108
+ return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
109
+ }
46
110
  let args;
47
111
  try {
48
112
  args = argsRaw.trim() ? JSON.parse(argsRaw) : {};
49
113
  }
50
114
  catch {
51
- return t('toolError.invalidJson', { name, arguments: argsRaw });
115
+ return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
52
116
  }
117
+ const capabilities = getToolCapabilities(tool);
118
+ const mutationBefore = getCurrentTurnMutationState();
53
119
  try {
54
- // 沙箱先重写/校验路径,保证快照与实际执行目标完全一致。
55
- const sbErr = enforceSandbox(name, args);
56
- if (sbErr)
57
- return sbErr;
58
- const pathCapture = (name === 'write_file' || name === 'edit_file') &&
59
- typeof args.path === 'string' &&
60
- args.path
120
+ const sandboxError = enforceSandbox(name, args);
121
+ if (sandboxError) {
122
+ return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
123
+ }
124
+ const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
61
125
  ? beginPathMutation(args.path)
62
126
  : null;
63
- // shell MCP 的副作用无法从参数可靠推断:以工作区前后状态识别实际改动。
64
- // task 本身不扫描;其子 agent 共享当前轮,并在各自真实写工具处记账。
65
- const workspaceCapture = name === 'run_command' || name.startsWith('mcp__')
127
+ // 进程和未知扩展可能间接改动任意文件;已声明 write 的非文件工具自行管理其状态。
128
+ const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
66
129
  ? beginWorkspaceMutation()
67
130
  : null;
131
+ let raw;
68
132
  try {
69
- return await tool.execute(args, {
133
+ raw = await tool.execute(args, {
70
134
  signal,
71
135
  dropContext: opts?.dropContext,
72
136
  });
@@ -77,11 +141,30 @@ export async function executeTool(name, argsRaw, signal, opts) {
77
141
  if (workspaceCapture)
78
142
  endWorkspaceMutation(workspaceCapture, name);
79
143
  }
144
+ const mutationAfter = getCurrentTurnMutationState();
145
+ const changedFiles = mutationAfter.version !== mutationBefore.version
146
+ ? mutationAfter.changedFiles.map((item) => item.path)
147
+ : [];
148
+ if (signal?.aborted) {
149
+ return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
150
+ }
151
+ return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
80
152
  }
81
- catch (e) {
82
- return t('toolError.execution', {
153
+ catch (error) {
154
+ const mutationAfter = getCurrentTurnMutationState();
155
+ const changedFiles = mutationAfter.version !== mutationBefore.version
156
+ ? mutationAfter.changedFiles.map((item) => item.path)
157
+ : [];
158
+ if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
159
+ return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);
160
+ }
161
+ return terminalOutcome('error', 'EXECUTION_ERROR', t('toolError.execution', {
83
162
  name,
84
- message: e instanceof Error ? e.message : String(e),
85
- });
163
+ message: error instanceof Error ? error.message : String(error),
164
+ }), startedAt, changedFiles);
86
165
  }
87
166
  }
167
+ /** 字符串兼容入口:现有调用方、TUI 和 LLM history 无需同步迁移。 */
168
+ export async function executeTool(name, argsRaw, signal, opts) {
169
+ return (await executeToolOutcome(name, argsRaw, signal, opts)).output;
170
+ }
@@ -0,0 +1,149 @@
1
+ import path from 'node:path';
2
+ const isWindows = process.platform === 'win32';
3
+ const ROOT_CONFIG_NAMES = new Set([
4
+ 'package.json', 'pnpm-workspace.yaml', 'pnpm-workspace.yml',
5
+ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
6
+ ]);
7
+ function comparisonKey(value) {
8
+ const resolved = path.resolve(value);
9
+ return isWindows ? resolved.toLowerCase() : resolved;
10
+ }
11
+ function isInside(parent, child) {
12
+ const relative = path.relative(comparisonKey(parent), comparisonKey(child));
13
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
14
+ }
15
+ function toNativeSeparators(value) {
16
+ return value.replace(/[\\/]/g, path.sep);
17
+ }
18
+ function canonicalize(profile, input, base) {
19
+ if (!input.trim() || input.includes('\0'))
20
+ return { input, reason: 'invalid_path' };
21
+ try {
22
+ const absolute = path.resolve(base, toNativeSeparators(input));
23
+ if (!isInside(profile.root, absolute))
24
+ return { input, reason: 'outside_project' };
25
+ const relative = path.relative(profile.root, absolute);
26
+ return {
27
+ absolute,
28
+ key: comparisonKey(absolute),
29
+ display: relative === '' ? '.' : relative.split(path.sep).join('/'),
30
+ };
31
+ }
32
+ catch {
33
+ return { input, reason: 'invalid_path' };
34
+ }
35
+ }
36
+ function matchesAnyRoot(file, roots) {
37
+ return roots.some((root) => isInside(root, file));
38
+ }
39
+ function classify(file, owner) {
40
+ if (matchesAnyRoot(file, owner.fixtureRoots))
41
+ return 'fixture';
42
+ if (matchesAnyRoot(file, owner.generatedRoots))
43
+ return 'generated';
44
+ if (matchesAnyRoot(file, owner.vendorRoots))
45
+ return 'vendor';
46
+ if (matchesAnyRoot(file, owner.testRoots))
47
+ return 'test';
48
+ if (matchesAnyRoot(file, owner.sourceRoots))
49
+ return 'source';
50
+ return 'other';
51
+ }
52
+ function rootConfigReason(profile, file) {
53
+ const workspaceKeys = new Set(profile.workspaceConfigPaths.map(comparisonKey));
54
+ if (workspaceKeys.has(file.key))
55
+ return 'workspace_config_change';
56
+ const rootPackage = profile.packages.find((item) => comparisonKey(item.root) === comparisonKey(profile.root));
57
+ const configKeys = new Set([
58
+ ...(rootPackage?.tsconfigPaths ?? []),
59
+ ...(rootPackage?.testConfigPaths ?? []),
60
+ ...(rootPackage?.lintConfigPaths ?? []),
61
+ ].map(comparisonKey));
62
+ if (configKeys.has(file.key))
63
+ return 'root_config_change';
64
+ if (comparisonKey(path.dirname(file.absolute)) !== comparisonKey(profile.root))
65
+ return null;
66
+ const name = path.basename(file.absolute).toLowerCase();
67
+ if (ROOT_CONFIG_NAMES.has(name) || /^tsconfig(?:\..+)?\.json$/i.test(name)) {
68
+ return name.startsWith('pnpm-workspace') ? 'workspace_config_change' : 'root_config_change';
69
+ }
70
+ return null;
71
+ }
72
+ function addReason(selections, packageProfile, reason) {
73
+ const key = comparisonKey(packageProfile.root);
74
+ let selected = selections.get(key);
75
+ if (!selected) {
76
+ selected = { package: packageProfile, reasons: [] };
77
+ selections.set(key, selected);
78
+ }
79
+ if (!selected.reasons.some((item) => item.kind === reason.kind
80
+ && item.changedPath === reason.changedPath
81
+ && item.sourcePackage === reason.sourcePackage)) {
82
+ selected.reasons.push(reason);
83
+ }
84
+ }
85
+ /** Map changed paths to their longest matching package roots without touching the filesystem. */
86
+ export function resolveAffectedPackages(profile, changedFiles, options) {
87
+ const canonicalByKey = new Map();
88
+ const rejected = [];
89
+ for (const input of changedFiles) {
90
+ const result = canonicalize(profile, input, path.resolve(options.changedFilesBase));
91
+ if ('reason' in result)
92
+ rejected.push(result);
93
+ else if (!canonicalByKey.has(result.key))
94
+ canonicalByKey.set(result.key, result);
95
+ }
96
+ const canonical = [...canonicalByKey.values()];
97
+ const packageByDepth = [...profile.packages].sort((left, right) => comparisonKey(right.root).length - comparisonKey(left.root).length);
98
+ const selections = new Map();
99
+ const unmatchedFiles = [];
100
+ let affectsAll = false;
101
+ for (const file of canonical) {
102
+ const configReason = rootConfigReason(profile, file);
103
+ if (configReason) {
104
+ affectsAll = true;
105
+ for (const packageProfile of profile.packages) {
106
+ addReason(selections, packageProfile, {
107
+ kind: configReason,
108
+ changedPath: file.display,
109
+ classification: 'other',
110
+ });
111
+ }
112
+ continue;
113
+ }
114
+ const owner = packageByDepth.find((packageProfile) => isInside(packageProfile.root, file.absolute));
115
+ if (!owner) {
116
+ unmatchedFiles.push(file.display);
117
+ continue;
118
+ }
119
+ addReason(selections, owner, {
120
+ kind: 'direct_change',
121
+ changedPath: file.display,
122
+ classification: classify(file.absolute, owner),
123
+ });
124
+ }
125
+ if (!affectsAll && options.expandDependents && selections.size > 0) {
126
+ const direct = profile.packages.filter((item) => selections.has(comparisonKey(item.root)));
127
+ const sourcePackage = direct.map((item) => item.name).join(', ');
128
+ for (const expanded of options.expandDependents(direct, profile)) {
129
+ const packageProfile = profile.packages.find((item) => comparisonKey(item.root) === comparisonKey(expanded.root));
130
+ if (!packageProfile || selections.has(comparisonKey(packageProfile.root)))
131
+ continue;
132
+ addReason(selections, packageProfile, {
133
+ kind: 'dependent_change',
134
+ changedPath: canonical[0]?.display ?? '.',
135
+ classification: 'other',
136
+ sourcePackage,
137
+ });
138
+ }
139
+ }
140
+ return {
141
+ packages: profile.packages
142
+ .map((item) => selections.get(comparisonKey(item.root)))
143
+ .filter((item) => item !== undefined),
144
+ canonicalChangedFiles: canonical.map((item) => item.display),
145
+ rejected,
146
+ unmatchedFiles,
147
+ affectsAll,
148
+ };
149
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { discoverProjectProfile } from './profile.js';
4
+ const SCRIPT_PRIORITY = ['typecheck', 'test', 'build'];
5
+ const isWindows = process.platform === 'win32';
6
+ function samePath(left, right) {
7
+ const normalizedLeft = path.resolve(left);
8
+ const normalizedRight = path.resolve(right);
9
+ return isWindows
10
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
11
+ : normalizedLeft === normalizedRight;
12
+ }
13
+ /** Discover one lowest-cost validation command for a package in a project profile. */
14
+ export function discoverPackageValidationCommand(profile, packageProfile) {
15
+ const script = SCRIPT_PRIORITY.find((name) => typeof packageProfile.scripts[name] === 'string');
16
+ if (!script)
17
+ return null;
18
+ return {
19
+ script,
20
+ command: `${profile.packageManager} run ${script}`,
21
+ packageManager: profile.packageManager,
22
+ cwd: packageProfile.root,
23
+ };
24
+ }
25
+ /** Compatibility wrapper that discovers the root package validation command. */
26
+ export function discoverValidationCommand(root) {
27
+ const resolvedRoot = path.resolve(root);
28
+ if (!existsSync(path.join(resolvedRoot, 'package.json'))) {
29
+ return { command: null, reason: 'no_package_json' };
30
+ }
31
+ try {
32
+ const profile = discoverProjectProfile(resolvedRoot);
33
+ const rootPackage = profile.packages.find((item) => samePath(item.root, resolvedRoot));
34
+ const command = rootPackage ? discoverPackageValidationCommand(profile, rootPackage) : null;
35
+ return command ? { command } : { command: null, reason: 'no_validation_script' };
36
+ }
37
+ catch {
38
+ return { command: null, reason: 'no_validation_script' };
39
+ }
40
+ }