mustflow 2.16.0 → 2.18.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 (36) hide show
  1. package/README.md +5 -5
  2. package/dist/cli/commands/classify.js +13 -3
  3. package/dist/cli/commands/dashboard.js +2 -1
  4. package/dist/cli/commands/impact.js +13 -3
  5. package/dist/cli/commands/run.js +86 -11
  6. package/dist/cli/commands/upgrade.js +3 -1
  7. package/dist/cli/commands/verify.js +9 -1
  8. package/dist/cli/commands/version.js +1 -1
  9. package/dist/cli/i18n/en.js +8 -1
  10. package/dist/cli/i18n/es.js +8 -1
  11. package/dist/cli/i18n/fr.js +8 -1
  12. package/dist/cli/i18n/hi.js +8 -1
  13. package/dist/cli/i18n/ko.js +7 -0
  14. package/dist/cli/i18n/zh.js +7 -0
  15. package/dist/cli/lib/git-changes.js +25 -2
  16. package/dist/cli/lib/local-index/constants.js +4 -1
  17. package/dist/cli/lib/local-index/index.js +22 -5
  18. package/dist/cli/lib/npm-version-check.js +71 -1
  19. package/dist/cli/lib/repo-map.js +81 -28
  20. package/dist/cli/lib/run-plan.js +25 -2
  21. package/dist/cli/lib/validation/index.js +2 -1
  22. package/dist/core/check-issues.js +2 -0
  23. package/dist/core/command-contract-rules.js +104 -2
  24. package/dist/core/command-contract-validation.js +14 -2
  25. package/dist/core/command-intent-eligibility.js +9 -1
  26. package/dist/core/command-output-limits.js +5 -0
  27. package/dist/core/contract-lint.js +10 -1
  28. package/package.json +1 -1
  29. package/schemas/README.md +3 -3
  30. package/schemas/change-verification-report.schema.json +2 -1
  31. package/schemas/contract-lint-report.schema.json +2 -1
  32. package/schemas/explain-report.schema.json +1 -0
  33. package/schemas/latest-run-pointer.schema.json +1 -0
  34. package/schemas/verify-report.schema.json +1 -0
  35. package/schemas/verify-run-manifest.schema.json +1 -0
  36. package/templates/default/manifest.toml +1 -1
@@ -664,8 +664,12 @@ export const zhMessages = {
664
664
  "run.error.unsafeIntentDetail": "请使用 shell 安全的意图名称。",
665
665
  "run.error.blockedShellBackground": '意图 "{intent}" 已被阻止。{detail}',
666
666
  "run.error.blockedShellBackgroundDetail": "Shell 命令不得启动后台工作。",
667
+ "run.error.blockedLongRunningCommand": '意图 "{intent}" 已被阻止。{detail}',
668
+ "run.error.blockedLongRunningCommandDetail": "argv 必须描述会结束的单次命令,而不是开发服务器、监听命令、shell 包装器、解释器循环或后台进程。",
667
669
  "run.error.cwdOutsideProject": '命令 "{intent}" 的 cwd 无效:{detail}',
668
670
  "run.error.cwdOutsideProjectDetail": "意图 cwd 必须位于当前根目录内。",
671
+ "run.error.maxOutputBytes": '命令 "{intent}" 的 max_output_bytes 无效。{detail}',
672
+ "run.error.maxOutputBytesDetail": "输出限制必须保持在允许的最大值内。",
669
673
  "run.error.conflictingPreviewModes": "只能使用 --dry-run 或 --plan-only,不能同时使用",
670
674
  "run.error.timedOut": '命令 "{intent}" 在 {seconds} 秒后超时',
671
675
  "run.error.startFailed": '命令 "{intent}" 启动失败:{message}',
@@ -727,6 +731,7 @@ export const zhMessages = {
727
731
  "classify.source.changed": "变更文件",
728
732
  "classify.source.paths": "指定路径",
729
733
  "classify.error.missingInput": "请指定 --changed 或至少一个路径",
734
+ "classify.error.changed_files_unavailable": "无法通过 git status 检查变更文件",
730
735
  "classify.error.write_path_outside_root": "分类报告路径必须位于 mustflow 根目录内",
731
736
  "impact.help.summary": "在不修改文件的情况下报告变更路径是否需要包或模板版本决策。",
732
737
  "impact.help.option.changed": "从 git status --short --untracked-files=all 读取路径",
@@ -741,6 +746,7 @@ export const zhMessages = {
741
746
  "impact.label.affectedVersionSources": "受影响的版本来源",
742
747
  "impact.label.affectedSurfaces": "受影响的公开表面",
743
748
  "impact.error.missingInput": "请指定 --changed 或至少一个路径",
749
+ "impact.error.changed_files_unavailable": "无法通过 git status 检查变更文件",
744
750
  "verify.help.summary": "运行由 required_after 元数据选出的已配置验证意图。",
745
751
  "verify.help.option.reason": "选择要验证的 required_after 原因",
746
752
  "verify.help.option.fromClassification": "从此仓库内的 mf classify 报告读取验证原因",
@@ -768,6 +774,7 @@ export const zhMessages = {
768
774
  "verify.error.plan_root_mismatch": "分类报告必须来自当前 mustflow 根目录",
769
775
  "verify.error.missing_plan_reasons": "分类报告必须包含 summary.validationReasons",
770
776
  "verify.error.plan_path_outside_root": "分类报告路径必须位于 mustflow 根目录内",
777
+ "verify.error.changed_files_unavailable": "无法通过 git status 检查变更文件",
771
778
  "verify.error.invalid_repro_evidence_file": "复现证据必须是包含结构化证据字段的可读取 JSON 摘要",
772
779
  "verify.error.unsupported_repro_evidence_source": "复现证据输入必须使用 command repro-evidence",
773
780
  "verify.error.invalid_external_evidence_file": "外部证据必须是包含 checks 的可读取 JSON 摘要",
@@ -1,5 +1,13 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { parseGitStatusOutput } from '../../core/change-classification.js';
3
+ export class GitChangedFilesError extends Error {
4
+ result;
5
+ constructor(result) {
6
+ super('git_changed_files_unavailable');
7
+ this.name = 'GitChangedFilesError';
8
+ this.result = result;
9
+ }
10
+ }
3
11
  export function readGitChangedFiles(projectRoot) {
4
12
  const result = spawnSync('git', ['status', '--short', '--untracked-files=all'], {
5
13
  cwd: projectRoot,
@@ -7,7 +15,22 @@ export function readGitChangedFiles(projectRoot) {
7
15
  windowsHide: true,
8
16
  });
9
17
  if (result.status !== 0 || typeof result.stdout !== 'string') {
10
- return [];
18
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
19
+ const message = result.error?.message ??
20
+ (stderr || (result.status === null ? 'git status did not complete' : `git status exited with code ${result.status}`));
21
+ return {
22
+ ok: false,
23
+ message,
24
+ status: result.status,
25
+ stderr,
26
+ };
27
+ }
28
+ return { ok: true, files: parseGitStatusOutput(result.stdout) };
29
+ }
30
+ export function requireGitChangedFiles(projectRoot) {
31
+ const result = readGitChangedFiles(projectRoot);
32
+ if (!result.ok) {
33
+ throw new GitChangedFilesError(result);
11
34
  }
12
- return parseGitStatusOutput(result.stdout);
35
+ return result.files;
13
36
  }
@@ -1,4 +1,4 @@
1
- export const LOCAL_INDEX_SCHEMA_VERSION = '19';
1
+ export const LOCAL_INDEX_SCHEMA_VERSION = '20';
2
2
  export const LOCAL_INDEX_PARSER_VERSION = '1';
3
3
  export const DEFAULT_DATABASE_RELATIVE_PATH = '.mustflow/cache/mustflow.sqlite';
4
4
  export const LATEST_RUN_STATE_RELATIVE_PATH = '.mustflow/state/runs/latest.json';
@@ -22,6 +22,9 @@ export const SEARCH_MATCH_CONTEXT_AFTER_CHARS = 96;
22
22
  export const SEARCH_MATCH_TRUNCATION_MARKER = '...';
23
23
  export const SEARCH_NGRAM_MIN_LENGTH = 2;
24
24
  export const SEARCH_NGRAM_MAX_LENGTH = 3;
25
+ export const SEARCH_NGRAM_MAX_TOKEN_CHARS = 64;
26
+ export const SEARCH_NGRAM_MAX_GRAMS_PER_TARGET = 512;
27
+ export const SOURCE_INDEX_MAX_FILE_BYTES = 262144;
25
28
  export const SEARCH_BACKEND_FTS5 = 'fts5';
26
29
  export const SEARCH_BACKEND_TABLE_SCAN = 'table_scan';
27
30
  export const TEST_DISABLE_FTS5_ENV = 'MUSTFLOW_TEST_DISABLE_FTS5';
@@ -7,7 +7,7 @@ import { readTomlFile } from '../toml.js';
7
7
  import { collectSourceAnchorIndexRecords, hasHighRiskSourceAnchorRiskTags, } from '../../../core/source-anchor-status.js';
8
8
  import { normalizeCommandEffects } from '../../../core/command-effects.js';
9
9
  import { listChangeClassificationRuleDescriptors } from '../../../core/change-classification.js';
10
- import { DEFAULT_DATABASE_RELATIVE_PATH, DEFAULT_PROMPT_CACHE_STABLE_READ, DEFAULT_PROMPT_CACHE_TASK_SOURCES, DEFAULT_PROMPT_CACHE_VOLATILE_SOURCES, INDEX_CONFIG_RELATIVE_PATH, LOCAL_INDEX_CONTENT_MODE, LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS, LOCAL_INDEX_PARSER_VERSION, LOCAL_INDEX_SCHEMA_VERSION, LOCAL_INDEX_STORE_FULL_CONTENT, LATEST_RUN_STATE_RELATIVE_PATH, MAX_SEARCH_MATCH_SNIPPET_CHARS, MAX_SNIPPET_BYTES_PER_DOCUMENT, MUSTFLOW_RELATIVE_PATH, SEARCH_BACKEND_FTS5, SEARCH_BACKEND_TABLE_SCAN, SEARCH_MATCH_CONTEXT_AFTER_CHARS, SEARCH_MATCH_CONTEXT_BEFORE_CHARS, SEARCH_MATCH_TRUNCATION_MARKER, SEARCH_NGRAM_MAX_LENGTH, SEARCH_NGRAM_MIN_LENGTH, TEST_DISABLE_FTS5_ENV, } from './constants.js';
10
+ import { DEFAULT_DATABASE_RELATIVE_PATH, DEFAULT_PROMPT_CACHE_STABLE_READ, DEFAULT_PROMPT_CACHE_TASK_SOURCES, DEFAULT_PROMPT_CACHE_VOLATILE_SOURCES, INDEX_CONFIG_RELATIVE_PATH, LOCAL_INDEX_CONTENT_MODE, LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS, LOCAL_INDEX_PARSER_VERSION, LOCAL_INDEX_SCHEMA_VERSION, LOCAL_INDEX_STORE_FULL_CONTENT, LATEST_RUN_STATE_RELATIVE_PATH, MAX_SEARCH_MATCH_SNIPPET_CHARS, MAX_SNIPPET_BYTES_PER_DOCUMENT, MUSTFLOW_RELATIVE_PATH, SEARCH_BACKEND_FTS5, SEARCH_BACKEND_TABLE_SCAN, SEARCH_MATCH_CONTEXT_AFTER_CHARS, SEARCH_MATCH_CONTEXT_BEFORE_CHARS, SEARCH_MATCH_TRUNCATION_MARKER, SEARCH_NGRAM_MAX_GRAMS_PER_TARGET, SEARCH_NGRAM_MAX_LENGTH, SEARCH_NGRAM_MAX_TOKEN_CHARS, SEARCH_NGRAM_MIN_LENGTH, SOURCE_INDEX_MAX_FILE_BYTES, TEST_DISABLE_FTS5_ENV, } from './constants.js';
11
11
  import { loadSqlJs } from './sql.js';
12
12
  export function getLocalIndexDatabasePath(projectRoot) {
13
13
  return path.join(projectRoot, ...DEFAULT_DATABASE_RELATIVE_PATH.split('/'));
@@ -83,11 +83,12 @@ function readPositiveInteger(table, key) {
83
83
  }
84
84
  function readLocalIndexSourceConfig(projectRoot) {
85
85
  const sourceIndexTable = readNestedTable(readIndexToml(projectRoot), 'source_index');
86
+ const configuredMaxFileBytes = readPositiveInteger(sourceIndexTable, 'max_file_bytes');
86
87
  return {
87
88
  enabledByDefault: readBoolean(sourceIndexTable, 'enabled_by_default') === true,
88
89
  include: readOptionalStringArray(sourceIndexTable, 'include') ?? [],
89
90
  exclude: readOptionalStringArray(sourceIndexTable, 'exclude') ?? [],
90
- maxFileBytes: readPositiveInteger(sourceIndexTable, 'max_file_bytes'),
91
+ maxFileBytes: Math.min(configuredMaxFileBytes ?? SOURCE_INDEX_MAX_FILE_BYTES, SOURCE_INDEX_MAX_FILE_BYTES),
91
92
  allowedExtensions: readOptionalStringArray(sourceIndexTable, 'allowed_extensions') ?? [],
92
93
  };
93
94
  }
@@ -321,10 +322,14 @@ function buildSearchNgrams(values) {
321
322
  const grams = new Set();
322
323
  for (const value of values) {
323
324
  for (const token of extractSearchTokens(value)) {
324
- const maxLength = Math.min(SEARCH_NGRAM_MAX_LENGTH, token.length);
325
+ const boundedToken = token.slice(0, SEARCH_NGRAM_MAX_TOKEN_CHARS);
326
+ const maxLength = Math.min(SEARCH_NGRAM_MAX_LENGTH, boundedToken.length);
325
327
  for (let length = SEARCH_NGRAM_MIN_LENGTH; length <= maxLength; length += 1) {
326
- for (let index = 0; index <= token.length - length; index += 1) {
327
- grams.add(token.slice(index, index + length));
328
+ for (let index = 0; index <= boundedToken.length - length; index += 1) {
329
+ grams.add(boundedToken.slice(index, index + length));
330
+ if (grams.size >= SEARCH_NGRAM_MAX_GRAMS_PER_TARGET) {
331
+ return [...grams].sort((left, right) => left.localeCompare(right));
332
+ }
328
333
  }
329
334
  }
330
335
  }
@@ -1650,6 +1655,18 @@ function populateDatabase(database, capabilities, documents, skills, skillRoutes
1650
1655
  'max_snippet_bytes_per_document',
1651
1656
  String(MAX_SNIPPET_BYTES_PER_DOCUMENT),
1652
1657
  ]);
1658
+ database.run('INSERT INTO metadata (key, value) VALUES (?, ?)', [
1659
+ 'search_ngram_max_token_chars',
1660
+ String(SEARCH_NGRAM_MAX_TOKEN_CHARS),
1661
+ ]);
1662
+ database.run('INSERT INTO metadata (key, value) VALUES (?, ?)', [
1663
+ 'search_ngram_max_grams_per_target',
1664
+ String(SEARCH_NGRAM_MAX_GRAMS_PER_TARGET),
1665
+ ]);
1666
+ database.run('INSERT INTO metadata (key, value) VALUES (?, ?)', [
1667
+ 'source_index_max_file_bytes',
1668
+ String(SOURCE_INDEX_MAX_FILE_BYTES),
1669
+ ]);
1653
1670
  database.run('INSERT INTO metadata (key, value) VALUES (?, ?)', [
1654
1671
  'excluded_raw_data_kinds',
1655
1672
  LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS.join(','),
@@ -1,5 +1,42 @@
1
1
  const DEFAULT_NPM_REGISTRY_URL = 'https://registry.npmjs.org';
2
2
  const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 3_000;
3
+ const PACKAGE_MANAGER_COMMANDS = [
4
+ {
5
+ id: 'npm',
6
+ label: 'npm',
7
+ command(packageName) {
8
+ return `npm install -g ${packageName}@latest`;
9
+ },
10
+ },
11
+ {
12
+ id: 'bun',
13
+ label: 'bun',
14
+ command(packageName) {
15
+ return `bun add -g ${packageName}@latest`;
16
+ },
17
+ },
18
+ {
19
+ id: 'pnpm',
20
+ label: 'pnpm',
21
+ command(packageName) {
22
+ return `pnpm add -g ${packageName}@latest`;
23
+ },
24
+ },
25
+ {
26
+ id: 'yarn',
27
+ label: 'yarn',
28
+ command(packageName) {
29
+ return `yarn global add ${packageName}@latest`;
30
+ },
31
+ },
32
+ {
33
+ id: 'deno',
34
+ label: 'deno',
35
+ command(packageName) {
36
+ return `deno install -g -A -n mf npm:${packageName}@latest`;
37
+ },
38
+ },
39
+ ];
3
40
  function isRecord(value) {
4
41
  return typeof value === 'object' && value !== null && !Array.isArray(value);
5
42
  }
@@ -65,6 +102,37 @@ function getTimeoutMs() {
65
102
  const parsed = rawValue ? Number(rawValue) : DEFAULT_VERSION_CHECK_TIMEOUT_MS;
66
103
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_VERSION_CHECK_TIMEOUT_MS;
67
104
  }
105
+ function detectPackageManagerId() {
106
+ const signals = [
107
+ process.env.npm_config_user_agent,
108
+ process.env.npm_execpath,
109
+ process.execPath,
110
+ process.argv[1],
111
+ import.meta.url,
112
+ ]
113
+ .filter((signal) => typeof signal === 'string' && signal.length > 0)
114
+ .map((signal) => signal.toLowerCase());
115
+ for (const id of ['bun', 'pnpm', 'yarn', 'deno', 'npm']) {
116
+ if (signals.some((signal) => signal.includes(id))) {
117
+ return id;
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ function getPackageInstallCommands(packageName) {
123
+ const detectedId = detectPackageManagerId();
124
+ const commands = [...PACKAGE_MANAGER_COMMANDS];
125
+ const recommendedIndex = detectedId ? commands.findIndex((entry) => entry.id === detectedId) : -1;
126
+ if (recommendedIndex > 0) {
127
+ const [recommended] = commands.splice(recommendedIndex, 1);
128
+ commands.unshift(recommended);
129
+ }
130
+ return commands.map((entry, index) => ({
131
+ manager: entry.label,
132
+ command: entry.command(packageName),
133
+ recommended: index === 0 && detectedId === entry.id,
134
+ }));
135
+ }
68
136
  function buildLatestPackageUrl(registryUrl, packageName) {
69
137
  const trimmedRegistryUrl = registryUrl.replace(/\/+$/u, '');
70
138
  const encodedPackageName = packageName.startsWith('@')
@@ -86,12 +154,14 @@ export async function checkNpmLatestVersion(metadata) {
86
154
  if (!latestVersion) {
87
155
  throw new Error('npm registry response did not include a version');
88
156
  }
157
+ const updateCommands = getPackageInstallCommands(metadata.name);
89
158
  return {
90
159
  packageName: metadata.name,
91
160
  currentVersion: metadata.version,
92
161
  latestVersion,
93
162
  updateAvailable: comparePackageVersions(metadata.version, latestVersion) < 0,
94
163
  registryUrl,
95
- updateCommand: `npm install -g ${metadata.name}@latest`,
164
+ updateCommand: updateCommands[0]?.command ?? `npm install -g ${metadata.name}@latest`,
165
+ updateCommands,
96
166
  };
97
167
  }
@@ -1,8 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, lstatSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
- import { listFilesRecursive, toPosixPath } from './filesystem.js';
5
+ import { toPosixPath } from './filesystem.js';
6
6
  import { readTomlFile } from './toml.js';
7
7
  const DEFAULT_DEPTH = 3;
8
8
  const REPO_MAP_DOC_ID = 'repo-map';
@@ -241,7 +241,7 @@ function getRepoMapConfig(projectRoot) {
241
241
  };
242
242
  }
243
243
  function getGitFiles(projectRoot) {
244
- const result = spawnSync('git', ['ls-files'], {
244
+ const result = spawnSync('git', ['ls-files', '-z'], {
245
245
  cwd: projectRoot,
246
246
  encoding: 'utf8',
247
247
  });
@@ -249,16 +249,43 @@ function getGitFiles(projectRoot) {
249
249
  return [];
250
250
  }
251
251
  return result.stdout
252
- .split(/\r?\n/)
253
- .map((line) => line.trim())
252
+ .split('\0')
253
+ .map((line) => toPosixPath(line))
254
254
  .filter(Boolean);
255
255
  }
256
- function getRepositoryFiles(projectRoot) {
256
+ function isAnchorCandidatePath(relativePath, priorityPaths) {
257
+ return priorityPaths.has(relativePath) || Boolean(getAnchorDescription(relativePath));
258
+ }
259
+ function listAnchorCandidateFilesRecursive(rootPath, depth, priorityPaths) {
260
+ const results = [];
261
+ function visit(currentPath, directoryDepth) {
262
+ for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
263
+ const entryPath = path.join(currentPath, entry.name);
264
+ const relativePath = toPosixPath(path.relative(rootPath, entryPath));
265
+ if (entry.isDirectory()) {
266
+ if (EXCLUDED_SEGMENTS.has(entry.name) || directoryDepth >= depth) {
267
+ continue;
268
+ }
269
+ visit(entryPath, directoryDepth + 1);
270
+ continue;
271
+ }
272
+ if (entry.isFile() && isAnchorCandidatePath(relativePath, priorityPaths)) {
273
+ results.push(relativePath);
274
+ }
275
+ }
276
+ }
277
+ if (!existsSync(rootPath) || !statSync(rootPath).isDirectory()) {
278
+ return [];
279
+ }
280
+ visit(rootPath, 0);
281
+ return results.sort();
282
+ }
283
+ function getRepositoryFiles(projectRoot, depth, priorityPaths) {
257
284
  const files = new Set();
258
285
  for (const relativePath of getGitFiles(projectRoot)) {
259
286
  files.add(relativePath);
260
287
  }
261
- for (const relativePath of listFilesRecursive(projectRoot, { ignoredDirectoryNames: EXCLUDED_SEGMENTS })) {
288
+ for (const relativePath of listAnchorCandidateFilesRecursive(projectRoot, depth, priorityPaths)) {
262
289
  files.add(relativePath);
263
290
  }
264
291
  return Array.from(files);
@@ -306,7 +333,7 @@ function isUnderExcludedPrefix(relativePath, excludedPrefixes) {
306
333
  return excludedPrefixes.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix));
307
334
  }
308
335
  function discoverAnchors(projectRoot, depth, priorityPaths, nestedRepositories, excludedPrefixes) {
309
- return getRepositoryFiles(projectRoot)
336
+ return getRepositoryFiles(projectRoot, depth, priorityPaths)
310
337
  .filter(shouldIncludePath)
311
338
  .filter((relativePath) => !isUnderNestedRepository(relativePath, nestedRepositories))
312
339
  .filter((relativePath) => !isUnderExcludedPrefix(relativePath, excludedPrefixes))
@@ -350,13 +377,9 @@ function renderDirectoryAnchors(anchors) {
350
377
  function hasGitMarker(directoryPath) {
351
378
  return existsSync(path.join(directoryPath, '.git'));
352
379
  }
353
- function isDirectory(directoryPath) {
354
- try {
355
- return statSync(directoryPath).isDirectory();
356
- }
357
- catch {
358
- return false;
359
- }
380
+ function isRealPathInside(parentRealPath, childRealPath) {
381
+ const relative = path.relative(parentRealPath, childRealPath);
382
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
360
383
  }
361
384
  function isSafeWorkspaceRoot(projectRoot, workspaceRoot) {
362
385
  const absoluteRoot = path.resolve(projectRoot, workspaceRoot);
@@ -371,6 +394,32 @@ function getWorkspaceRootPrefixes(projectRoot, workspaceConfig) {
371
394
  .filter((workspaceRoot) => isSafeWorkspaceRoot(projectRoot, workspaceRoot))
372
395
  .map((workspaceRoot) => `${toPosixPath(workspaceRoot).replace(/\/+$/, '')}/`);
373
396
  }
397
+ function resolveSafeDirectoryTarget(projectRootRealPath, logicalPath, followSymlinks) {
398
+ try {
399
+ const stats = lstatSync(logicalPath);
400
+ if (stats.isSymbolicLink()) {
401
+ if (!followSymlinks) {
402
+ return undefined;
403
+ }
404
+ const realPath = realpathSync(logicalPath);
405
+ if (!isRealPathInside(projectRootRealPath, realPath) || !statSync(realPath).isDirectory()) {
406
+ return undefined;
407
+ }
408
+ return { logicalPath, realPath };
409
+ }
410
+ if (!stats.isDirectory()) {
411
+ return undefined;
412
+ }
413
+ const realPath = realpathSync(logicalPath);
414
+ if (!isRealPathInside(projectRootRealPath, realPath)) {
415
+ return undefined;
416
+ }
417
+ return { logicalPath, realPath };
418
+ }
419
+ catch {
420
+ return undefined;
421
+ }
422
+ }
374
423
  function collectNestedRepository(projectRoot, repositoryPath, anchorFiles) {
375
424
  const relativeRoot = `${toPosixPath(path.relative(projectRoot, repositoryPath))}/`;
376
425
  const existingAnchors = new Set();
@@ -422,31 +471,34 @@ function discoverNestedRepositories(projectRoot, mapConfig, workspaceConfig) {
422
471
  }
423
472
  const repositories = [];
424
473
  const seenRepositoryPaths = new Set();
425
- function visit(directoryPath, depth) {
474
+ const seenDirectoryPaths = new Set();
475
+ const projectRootRealPath = realpathSync(projectRoot);
476
+ function visit(directoryTarget, depth) {
426
477
  if (repositories.length >= workspaceConfig.maxRepositories || depth > workspaceConfig.maxDepth) {
427
478
  return;
428
479
  }
429
- if (hasGitMarker(directoryPath)) {
430
- const resolvedRepositoryPath = path.resolve(directoryPath);
480
+ if (seenDirectoryPaths.has(directoryTarget.realPath)) {
481
+ return;
482
+ }
483
+ seenDirectoryPaths.add(directoryTarget.realPath);
484
+ if (hasGitMarker(directoryTarget.logicalPath)) {
485
+ const resolvedRepositoryPath = directoryTarget.realPath;
431
486
  if (!seenRepositoryPaths.has(resolvedRepositoryPath)) {
432
487
  seenRepositoryPaths.add(resolvedRepositoryPath);
433
- repositories.push(collectNestedRepository(projectRoot, resolvedRepositoryPath, mapConfig.anchorFiles));
488
+ repositories.push(collectNestedRepository(projectRoot, directoryTarget.logicalPath, mapConfig.anchorFiles));
434
489
  }
435
490
  if (workspaceConfig.stopAtRepositoryRoot) {
436
491
  return;
437
492
  }
438
493
  }
439
- for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
440
- if (!entry.isDirectory()) {
441
- continue;
442
- }
494
+ for (const entry of readdirSync(directoryTarget.logicalPath, { withFileTypes: true })) {
443
495
  if (EXCLUDED_SEGMENTS.has(entry.name)) {
444
496
  continue;
445
497
  }
446
- if (entry.isSymbolicLink() && !workspaceConfig.followSymlinks) {
447
- continue;
498
+ const childDirectoryTarget = resolveSafeDirectoryTarget(projectRootRealPath, path.join(directoryTarget.logicalPath, entry.name), workspaceConfig.followSymlinks);
499
+ if (childDirectoryTarget) {
500
+ visit(childDirectoryTarget, depth + 1);
448
501
  }
449
- visit(path.join(directoryPath, entry.name), depth + 1);
450
502
  }
451
503
  }
452
504
  for (const workspaceRoot of workspaceConfig.roots) {
@@ -454,10 +506,11 @@ function discoverNestedRepositories(projectRoot, mapConfig, workspaceConfig) {
454
506
  continue;
455
507
  }
456
508
  const absoluteWorkspaceRoot = path.resolve(projectRoot, workspaceRoot);
457
- if (!isDirectory(absoluteWorkspaceRoot)) {
509
+ const workspaceTarget = resolveSafeDirectoryTarget(projectRootRealPath, absoluteWorkspaceRoot, workspaceConfig.followSymlinks);
510
+ if (!workspaceTarget) {
458
511
  continue;
459
512
  }
460
- visit(absoluteWorkspaceRoot, 0);
513
+ visit(workspaceTarget, 0);
461
514
  }
462
515
  return repositories.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
463
516
  }
@@ -4,6 +4,7 @@ import { resolveSafeProjectCwd } from '../../core/command-cwd.js';
4
4
  import { resolveCommandEnv } from '../../core/command-env.js';
5
5
  import { evaluateCommandIntentEligibility, } from '../../core/command-intent-eligibility.js';
6
6
  import { isRecord, readPositiveInteger, readString, readStringArray, } from '../../core/config-loading.js';
7
+ import { DEFAULT_COMMAND_MAX_OUTPUT_BYTES, MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage, } from '../../core/command-output-limits.js';
7
8
  import { t } from './i18n.js';
8
9
  function getSuccessExitCodes(intent) {
9
10
  const value = intent.success_exit_codes;
@@ -72,6 +73,24 @@ function getRunPlanMode(commandArgv, intent) {
72
73
  }
73
74
  return intent.mode === 'shell' ? 'shell' : null;
74
75
  }
76
+ function readEffectiveMaxOutputBytes(contract, intent) {
77
+ return readPositiveInteger(intent, 'max_output_bytes') ??
78
+ readPositiveInteger(contract.defaults, 'max_output_bytes') ??
79
+ DEFAULT_COMMAND_MAX_OUTPUT_BYTES;
80
+ }
81
+ function getMaxOutputBytesLimitDetail(contract, intent) {
82
+ const intentValue = readPositiveInteger(intent, 'max_output_bytes');
83
+ if (intentValue !== undefined) {
84
+ return intentValue > MAX_COMMAND_OUTPUT_BYTES ?
85
+ commandMaxOutputBytesLimitMessage('[commands.intents.<intent>].max_output_bytes') :
86
+ null;
87
+ }
88
+ const defaultValue = readPositiveInteger(contract.defaults, 'max_output_bytes');
89
+ if (defaultValue !== undefined && defaultValue > MAX_COMMAND_OUTPUT_BYTES) {
90
+ return commandMaxOutputBytesLimitMessage('[commands.defaults].max_output_bytes');
91
+ }
92
+ return null;
93
+ }
75
94
  function readRunIntentMetadata(contract, intent) {
76
95
  const configuredCwd = readString(intent, 'cwd') ?? readString(contract.defaults, 'default_cwd') ?? '.';
77
96
  const commandArgv = readStringArray(intent, 'argv');
@@ -84,7 +103,7 @@ function readRunIntentMetadata(contract, intent) {
84
103
  kind: readString(intent, 'kind') ?? null,
85
104
  configuredCwd,
86
105
  timeoutSeconds: readPositiveInteger(intent, 'timeout_seconds') ?? null,
87
- maxOutputBytes: readPositiveInteger(intent, 'max_output_bytes') ?? readPositiveInteger(contract.defaults, 'max_output_bytes') ?? 1_048_576,
106
+ maxOutputBytes: readEffectiveMaxOutputBytes(contract, intent),
88
107
  successExitCodes: getSuccessExitCodes(intent),
89
108
  commandArgv,
90
109
  shellCommand,
@@ -149,6 +168,10 @@ export function createRunPlan(projectRoot, contract, intentName, options = {}) {
149
168
  return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, eligibility.code, eligibility.detail);
150
169
  }
151
170
  const metadata = readRunIntentMetadata(contract, rawIntent);
171
+ const maxOutputBytesLimitDetail = getMaxOutputBytesLimitDetail(contract, rawIntent);
172
+ if (maxOutputBytesLimitDetail) {
173
+ return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'max_output_bytes_exceeds_limit', maxOutputBytesLimitDetail);
174
+ }
152
175
  let cwd;
153
176
  try {
154
177
  cwd = resolveSafeProjectCwd(projectRoot, metadata.configuredCwd);
@@ -207,7 +230,7 @@ function createSuggestedIntentSnippet(intentName, metadata, reasonCode) {
207
230
  return null;
208
231
  }
209
232
  let commandLines;
210
- if (reasonCode === 'blocked_shell_background_pattern') {
233
+ if (reasonCode === 'blocked_shell_background_pattern' || reasonCode === 'blocked_long_running_command_pattern') {
211
234
  commandLines = [`argv = ${formatTomlStringArray(['TODO_REPLACE_WITH_FINITE_COMMAND'])}`];
212
235
  }
213
236
  else if (metadata?.shellCommand) {
@@ -605,7 +605,8 @@ function validateStrictVersionSources(projectRoot, preferencesToml, versioningTo
605
605
  pushStrictIssue(issues, '[release.versioning] is enabled but no version source was detected; add .mustflow/config/versioning.toml or a package/template version source');
606
606
  }
607
607
  function validateStrictTemplateVersionSync(projectRoot, preferencesToml, issues) {
608
- const changedPaths = existsSync(path.join(projectRoot, '.git')) ? readGitChangedFiles(projectRoot) : undefined;
608
+ const changedPathResult = existsSync(path.join(projectRoot, '.git')) ? readGitChangedFiles(projectRoot) : undefined;
609
+ const changedPaths = changedPathResult?.ok ? changedPathResult.files : undefined;
609
610
  for (const issue of validateTemplateVersionSync(projectRoot, preferencesToml, changedPaths)) {
610
611
  if (issue.severity === 'warning') {
611
612
  pushStrictWarning(issues, issue.message);
@@ -4,10 +4,12 @@ const CHECK_ISSUE_ID_RULES = [
4
4
  ['mustflow.command_contract.configured_missing_lifecycle', /^Configured intent [^\s]+ must define lifecycle$/u],
5
5
  ['mustflow.command_contract.configured_missing_run_policy', /^Configured intent [^\s]+ must define run_policy$/u],
6
6
  ['mustflow.command_contract.oneshot_missing_timeout', /^Oneshot intent [^\s]+ must define timeout_seconds$/u],
7
+ ['mustflow.command_contract.max_output_bytes_exceeds_limit', /^\[commands\.(?:defaults|intents\.[^\]]+)\]\.max_output_bytes must be less than or equal to \d+$/u],
7
8
  ['mustflow.command_contract.oneshot_stdin_not_closed', /^Oneshot intent [^\s]+ must set stdin = "closed"$/u],
8
9
  ['mustflow.command_contract.long_running_agent_allowed', /^Long-running intent [^\s]+ must not use run_policy = "agent_allowed"$/u],
9
10
  ['mustflow.command_contract.executable_source_missing', /^Configured intent [^\s]+ must define argv or mode = "shell" with cmd$/u],
10
11
  ['mustflow.command_contract.shell_background_pattern', /^Shell intent [^\s]+ contains a blocked long-running or background pattern$/u],
12
+ ['mustflow.command_contract.long_running_command_pattern', /^Intent [^\s]+ contains a blocked long-running or background command pattern$/u],
11
13
  ['mustflow.command_contract.success_exit_codes_invalid', /^\[commands\.intents\.[^\]]+\]\.success_exit_codes must be an integer array$/u],
12
14
  ['mustflow.command_contract.effects_invalid', /^(?:Strict: )?(?:\[commands\.(?:resources|intents\.[^\]]+\.effects)[^\]]*\]|Command effect for intent [^\s]+ must define path, paths, or lock)/u],
13
15
  ['mustflow.command_contract.effect_path_escape', /^Strict: Command effect path must stay inside the current root:/u],
@@ -1,16 +1,39 @@
1
+ import path from 'node:path';
1
2
  import { readString, readStringArray } from './config-loading.js';
2
3
  const SAFE_COMMAND_INTENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/u;
4
+ const SHELL_WRAPPER_COMMANDS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'cmd', 'powershell', 'pwsh']);
5
+ const SHELL_EVALUATION_FLAGS = new Set(['-c', '/c', '-command', '-commandwithargs']);
6
+ const INTERPRETER_EVALUATION_FLAGS = new Map([
7
+ ['node', new Set(['-e', '--eval'])],
8
+ ['python', new Set(['-c'])],
9
+ ['python3', new Set(['-c'])],
10
+ ['py', new Set(['-c'])],
11
+ ['ruby', new Set(['-e'])],
12
+ ['perl', new Set(['-e'])],
13
+ ]);
14
+ const PACKAGE_SCRIPT_RUNNERS = new Set(['bun', 'npm', 'pnpm', 'yarn']);
15
+ const LONG_RUNNING_PACKAGE_SCRIPTS = new Set(['dev', 'start', 'serve', 'watch', 'preview']);
16
+ const LONG_RUNNING_EXECUTABLES = new Set(['nodemon', 'pm2', 'serve', 'http-server', 'live-server', 'webpack-dev-server']);
3
17
  export const BACKGROUND_SHELL_PATTERNS = [
4
- /\s&\s*$/u,
18
+ /(?:^|[^&])&(?!&)\s*$/u,
5
19
  /\bnohup\b/iu,
6
20
  /\bdisown\b/iu,
7
21
  /\bStart-Process\b/iu,
8
- /\bstart\s+/iu,
22
+ /(?:^|[;&|]\s*)start\s+/iu,
9
23
  /\bxdg-open\b/iu,
10
24
  /\bopen\s+/iu,
11
25
  /\bchrome(?:\.exe)?\b/iu,
12
26
  /\bchromium(?:\.exe)?\b/iu,
13
27
  ];
28
+ export const LONG_RUNNING_COMMAND_TEXT_PATTERNS = [
29
+ /\b(?:npm|pnpm|bun|yarn)\s+(?:run\s+)?(?:dev|start|serve|watch|preview)\b/iu,
30
+ /\b(?:nohup|disown)\b/iu,
31
+ /(?:^|[^&])&(?!&)\s*$/u,
32
+ /\bsetInterval\s*\(/u,
33
+ /\bwhile\s*(?:\(\s*true\s*\)|true)\b/iu,
34
+ /\bserve_forever\s*\(/iu,
35
+ /\bcreateServer\s*\([^)]*\)\s*\.listen\s*\(/u,
36
+ ];
14
37
  export function commandIntentNameIsSafe(intentName) {
15
38
  return SAFE_COMMAND_INTENT_NAME_PATTERN.test(intentName);
16
39
  }
@@ -25,3 +48,82 @@ export function shellCommandHasBlockedBackgroundPattern(command) {
25
48
  export function commandIntentHasBlockedShellBackgroundPattern(intent) {
26
49
  return intent.mode === 'shell' && typeof intent.cmd === 'string' && shellCommandHasBlockedBackgroundPattern(intent.cmd);
27
50
  }
51
+ function normalizeExecutableName(value) {
52
+ return path.basename(value).replace(/\.(?:cmd|exe|ps1)$/iu, '').toLowerCase();
53
+ }
54
+ function findFlagPayload(argv, flags) {
55
+ for (let index = 1; index < argv.length - 1; index += 1) {
56
+ if (flags.has(argv[index].toLowerCase())) {
57
+ return argv[index + 1];
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ function commandTextHasLongRunningPattern(command) {
63
+ return LONG_RUNNING_COMMAND_TEXT_PATTERNS.some((pattern) => pattern.test(command));
64
+ }
65
+ function readPackageScriptName(command, args) {
66
+ if (!PACKAGE_SCRIPT_RUNNERS.has(command)) {
67
+ return null;
68
+ }
69
+ if (args[0] === 'run' && args[1] && !args[1].startsWith('-')) {
70
+ return args[1];
71
+ }
72
+ if (args[0] && LONG_RUNNING_PACKAGE_SCRIPTS.has(args[0])) {
73
+ return args[0];
74
+ }
75
+ return null;
76
+ }
77
+ function argvHasBlockedLongRunningPattern(argv) {
78
+ const [rawCommand = '', ...args] = argv;
79
+ const command = normalizeExecutableName(rawCommand);
80
+ const shellPayload = SHELL_WRAPPER_COMMANDS.has(command) ? findFlagPayload(argv, SHELL_EVALUATION_FLAGS) : null;
81
+ if (shellPayload && (shellCommandHasBlockedBackgroundPattern(shellPayload) || commandTextHasLongRunningPattern(shellPayload))) {
82
+ return `shell wrapper payload contains a blocked long-running or background pattern: ${shellPayload}`;
83
+ }
84
+ const interpreterFlags = INTERPRETER_EVALUATION_FLAGS.get(command);
85
+ const interpreterPayload = interpreterFlags ? findFlagPayload(argv, interpreterFlags) : null;
86
+ if (interpreterPayload && commandTextHasLongRunningPattern(interpreterPayload)) {
87
+ return `interpreter evaluation payload contains a blocked long-running pattern: ${interpreterPayload}`;
88
+ }
89
+ const packageScriptName = readPackageScriptName(command, args);
90
+ if (packageScriptName && LONG_RUNNING_PACKAGE_SCRIPTS.has(packageScriptName)) {
91
+ return `package-manager script "${packageScriptName}" is commonly long-running`;
92
+ }
93
+ if (LONG_RUNNING_EXECUTABLES.has(command)) {
94
+ return `executable "${command}" is commonly long-running`;
95
+ }
96
+ if (command === 'vite' && !args.includes('build')) {
97
+ return 'vite without build is commonly a development server';
98
+ }
99
+ if (command === 'next' && ['dev', 'start'].includes(args[0] ?? '')) {
100
+ return `next ${args[0]} is commonly long-running`;
101
+ }
102
+ if (command === 'webpack' && (args.includes('--watch') || args.includes('-w') || args.includes('serve'))) {
103
+ return 'webpack watch or serve mode is commonly long-running';
104
+ }
105
+ if (command === 'tsc' && (args.includes('--watch') || args.includes('-w'))) {
106
+ return 'tsc watch mode is long-running';
107
+ }
108
+ return null;
109
+ }
110
+ export function commandIntentBlockedCommandPattern(intent) {
111
+ if (intent.mode === 'shell' && typeof intent.cmd === 'string' && shellCommandHasBlockedBackgroundPattern(intent.cmd)) {
112
+ return {
113
+ code: 'shell_background_pattern',
114
+ detail: 'Shell command contains a blocked long-running or background pattern.',
115
+ };
116
+ }
117
+ const argv = readStringArray(intent, 'argv');
118
+ if (!argv) {
119
+ return null;
120
+ }
121
+ const detail = argvHasBlockedLongRunningPattern(argv);
122
+ if (!detail) {
123
+ return null;
124
+ }
125
+ return {
126
+ code: 'long_running_command_pattern',
127
+ detail: `Argv command contains a blocked long-running or background pattern: ${detail}.`,
128
+ };
129
+ }