mustflow 2.22.17 → 2.22.47

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 (70) hide show
  1. package/README.md +6 -0
  2. package/dist/cli/commands/api.js +874 -0
  3. package/dist/cli/commands/dashboard.js +51 -4
  4. package/dist/cli/commands/explain.js +3 -2
  5. package/dist/cli/commands/help.js +0 -1
  6. package/dist/cli/commands/run.js +41 -4
  7. package/dist/cli/commands/verify.js +4 -43
  8. package/dist/cli/i18n/en.js +15 -0
  9. package/dist/cli/i18n/es.js +15 -0
  10. package/dist/cli/i18n/fr.js +15 -0
  11. package/dist/cli/i18n/hi.js +15 -0
  12. package/dist/cli/i18n/ko.js +15 -0
  13. package/dist/cli/i18n/zh.js +15 -0
  14. package/dist/cli/index.js +1 -0
  15. package/dist/cli/lib/cli-output.js +1 -1
  16. package/dist/cli/lib/command-registry.js +6 -0
  17. package/dist/cli/lib/dashboard-html/client-script.js +9 -0
  18. package/dist/cli/lib/dashboard-html/styles.js +48 -1
  19. package/dist/cli/lib/doc-review-ledger.js +1 -1
  20. package/dist/cli/lib/local-index/index.js +324 -298
  21. package/dist/cli/lib/repo-map.js +19 -5
  22. package/dist/cli/lib/validation/index.js +6 -2
  23. package/dist/core/active-run-locks.js +36 -8
  24. package/dist/core/atomic-state-write.js +5 -20
  25. package/dist/core/change-verification.js +18 -2
  26. package/dist/core/contract-lint.js +3 -3
  27. package/dist/core/public-json-contracts.js +48 -0
  28. package/dist/core/repeated-failure.js +1 -1
  29. package/dist/core/run-write-drift.js +30 -17
  30. package/dist/core/safe-filesystem.js +54 -5
  31. package/dist/core/skill-route-explanation.js +2 -1
  32. package/dist/core/source-anchors.js +7 -3
  33. package/dist/core/validation-ratchet.js +61 -18
  34. package/dist/core/verification-decision-graph.js +8 -1
  35. package/dist/core/verification-plan-id.js +44 -0
  36. package/package.json +1 -1
  37. package/schemas/README.md +6 -0
  38. package/schemas/command-catalog.schema.json +158 -0
  39. package/schemas/diff-risk.schema.json +74 -0
  40. package/schemas/health.schema.json +45 -0
  41. package/schemas/latest-evidence.schema.json +95 -0
  42. package/schemas/verification-plan.schema.json +245 -0
  43. package/schemas/workspace-summary.schema.json +282 -0
  44. package/templates/default/i18n.toml +139 -1
  45. package/templates/default/locales/en/.mustflow/skills/INDEX.md +24 -1
  46. package/templates/default/locales/en/.mustflow/skills/api-contract-change/SKILL.md +212 -0
  47. package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +184 -0
  48. package/templates/default/locales/en/.mustflow/skills/auth-permission-change/SKILL.md +194 -0
  49. package/templates/default/locales/en/.mustflow/skills/config-env-change/SKILL.md +189 -0
  50. package/templates/default/locales/en/.mustflow/skills/css-code-change/SKILL.md +199 -0
  51. package/templates/default/locales/en/.mustflow/skills/dart-code-change/SKILL.md +179 -0
  52. package/templates/default/locales/en/.mustflow/skills/database-migration-change/SKILL.md +178 -0
  53. package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +151 -0
  54. package/templates/default/locales/en/.mustflow/skills/elysia-code-change/SKILL.md +115 -0
  55. package/templates/default/locales/en/.mustflow/skills/file-path-cross-platform-change/SKILL.md +147 -0
  56. package/templates/default/locales/en/.mustflow/skills/flutter-code-change/SKILL.md +116 -0
  57. package/templates/default/locales/en/.mustflow/skills/go-code-change/SKILL.md +156 -0
  58. package/templates/default/locales/en/.mustflow/skills/hono-code-change/SKILL.md +117 -0
  59. package/templates/default/locales/en/.mustflow/skills/html-code-change/SKILL.md +173 -0
  60. package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +149 -0
  61. package/templates/default/locales/en/.mustflow/skills/python-code-change/SKILL.md +154 -0
  62. package/templates/default/locales/en/.mustflow/skills/release-publish-change/SKILL.md +172 -0
  63. package/templates/default/locales/en/.mustflow/skills/routes.toml +138 -0
  64. package/templates/default/locales/en/.mustflow/skills/rust-code-change/SKILL.md +154 -0
  65. package/templates/default/locales/en/.mustflow/skills/svelte-code-change/SKILL.md +186 -0
  66. package/templates/default/locales/en/.mustflow/skills/tailwind-code-change/SKILL.md +164 -0
  67. package/templates/default/locales/en/.mustflow/skills/tauri-code-change/SKILL.md +185 -0
  68. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +184 -0
  69. package/templates/default/locales/en/.mustflow/skills/unocss-code-change/SKILL.md +186 -0
  70. package/templates/default/manifest.toml +158 -1
@@ -69,6 +69,8 @@ const MACHINE_CONTRACT_ANCHOR_FILES = [
69
69
  'schema.graphql',
70
70
  'schema.prisma',
71
71
  ];
72
+ const ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILE_SET = new Set(ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILES);
73
+ const MACHINE_CONTRACT_ANCHOR_FILE_SET = new Set(MACHINE_CONTRACT_ANCHOR_FILES);
72
74
  const DEFAULT_NESTED_ANCHOR_FILES = [
73
75
  'AGENTS.md',
74
76
  'REPO_MAP.md',
@@ -386,7 +388,13 @@ function renderDirectoryAnchors(anchors) {
386
388
  const grouped = new Map();
387
389
  for (const anchor of anchors) {
388
390
  const directory = getDirectoryName(anchor.relativePath);
389
- grouped.set(directory, [...(grouped.get(directory) ?? []), anchor]);
391
+ const group = grouped.get(directory);
392
+ if (group) {
393
+ group.push(anchor);
394
+ }
395
+ else {
396
+ grouped.set(directory, [anchor]);
397
+ }
390
398
  }
391
399
  for (const directory of Array.from(grouped.keys()).sort((left, right) => {
392
400
  if (left === '/') {
@@ -469,13 +477,13 @@ function collectNestedRepository(projectRoot, repositoryPath, anchorFiles) {
469
477
  .filter((anchorFile) => EDITING_POLICY_ANCHORS.has(anchorFile) && existingAnchors.has(anchorFile))
470
478
  .map((anchorFile) => `${relativeRoot}${anchorFile}`);
471
479
  const rootDocuments = anchorFiles
472
- .filter((anchorFile) => ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILES.includes(anchorFile) && existingAnchors.has(anchorFile))
480
+ .filter((anchorFile) => ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILE_SET.has(anchorFile) && existingAnchors.has(anchorFile))
473
481
  .map((anchorFile) => ({
474
482
  label: NESTED_ROOT_DOC_LABELS.get(anchorFile) ?? 'root document',
475
483
  relativePath: `${relativeRoot}${anchorFile}`,
476
484
  }));
477
485
  const machineContracts = anchorFiles
478
- .filter((anchorFile) => MACHINE_CONTRACT_ANCHOR_FILES.includes(anchorFile) && existingAnchors.has(anchorFile))
486
+ .filter((anchorFile) => MACHINE_CONTRACT_ANCHOR_FILE_SET.has(anchorFile) && existingAnchors.has(anchorFile))
479
487
  .map((anchorFile) => `${relativeRoot}${anchorFile}`);
480
488
  const mustflowConfig = resolveAnchor('.mustflow/config/mustflow.toml');
481
489
  const commandContract = resolveAnchor('.mustflow/config/commands.toml');
@@ -502,9 +510,15 @@ function discoverNestedRepositories(projectRoot, mapConfig, workspaceConfig) {
502
510
  const repositories = [];
503
511
  const seenRepositoryPaths = new Set();
504
512
  const seenDirectoryPaths = new Set();
505
- const projectRootRealPath = realpathSync(projectRoot);
513
+ let projectRootRealPath;
514
+ try {
515
+ projectRootRealPath = realpathSync(projectRoot);
516
+ }
517
+ catch {
518
+ return [];
519
+ }
506
520
  function visit(directoryTarget, depth) {
507
- if (repositories.length >= workspaceConfig.maxRepositories || depth > workspaceConfig.maxDepth) {
521
+ if (repositories.length >= workspaceConfig.maxRepositories || depth >= workspaceConfig.maxDepth) {
508
522
  return;
509
523
  }
510
524
  if (seenDirectoryPaths.has(directoryTarget.realPath)) {
@@ -474,11 +474,15 @@ function validateSkills(projectRoot, issues) {
474
474
  function readSkillSectionIds(content) {
475
475
  return new Set([...content.matchAll(SKILL_SECTION_MARKER_PATTERN)].map((match) => match[1]));
476
476
  }
477
+ function findFrontmatterEnd(content) {
478
+ const match = /\n---(?:\r?\n|$)/u.exec(content.slice(3));
479
+ return match ? 3 + match.index : -1;
480
+ }
477
481
  function parseSimpleFrontmatter(content) {
478
482
  if (!content.startsWith('---')) {
479
483
  return {};
480
484
  }
481
- const end = content.indexOf('\n---', 3);
485
+ const end = findFrontmatterEnd(content);
482
486
  if (end === -1) {
483
487
  return {};
484
488
  }
@@ -510,7 +514,7 @@ function readFrontmatterLines(content) {
510
514
  if (!content.startsWith('---')) {
511
515
  return [];
512
516
  }
513
- const end = content.indexOf('\n---', 3);
517
+ const end = findFrontmatterEnd(content);
514
518
  if (end === -1) {
515
519
  return [];
516
520
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { commandEffectsConflict, normalizeCommandEffects, } from './command-effects.js';
5
5
  const ACTIVE_LOCK_SCHEMA_VERSION = '1';
@@ -8,8 +8,9 @@ const LOCK_ROOT_RELATIVE_PATH = '.mustflow/state/locks';
8
8
  const LOCK_MUTEX_STALE_MS = 30_000;
9
9
  const LOCK_MUTEX_WAIT_MS = 1_000;
10
10
  const LOCK_MUTEX_SLEEP_MS = 25;
11
+ const LOCK_MUTEX_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
11
12
  function sleep(milliseconds) {
12
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
13
+ Atomics.wait(LOCK_MUTEX_SLEEP_BUFFER, 0, 0, milliseconds);
13
14
  }
14
15
  function sha256(value) {
15
16
  return createHash('sha256').update(value).digest('hex');
@@ -194,20 +195,38 @@ function createRecord(projectRoot, intentName, effects, commandHash) {
194
195
  function acquireMutex(projectRoot) {
195
196
  const root = activeLockRoot(projectRoot);
196
197
  const mutex = activeLockMutexDirectory(projectRoot);
198
+ const ownerPath = path.join(mutex, 'owner.json');
199
+ const ownerToken = sha256(`${process.pid}:${Date.now()}:${process.hrtime.bigint()}`);
197
200
  mkdirSync(root, { recursive: true });
198
201
  let startedAt = Date.now();
199
202
  while (true) {
200
203
  try {
201
204
  mkdirSync(mutex);
202
- writeFileSync(path.join(mutex, 'owner.json'), JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
203
- return () => rmSync(mutex, { recursive: true, force: true });
205
+ const ownerRecord = { pid: process.pid, started_at: new Date().toISOString(), token: ownerToken };
206
+ try {
207
+ writeFileSync(ownerPath, JSON.stringify(ownerRecord, null, 2));
208
+ }
209
+ catch (error) {
210
+ rmSync(mutex, { recursive: true, force: true });
211
+ throw error;
212
+ }
213
+ return () => {
214
+ try {
215
+ const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
216
+ if (Number(owner.pid) === ownerRecord.pid && owner.token === ownerRecord.token) {
217
+ rmSync(mutex, { recursive: true, force: true });
218
+ }
219
+ }
220
+ catch {
221
+ // A missing or replaced owner file means this process no longer owns the mutex.
222
+ }
223
+ };
204
224
  }
205
225
  catch (error) {
206
226
  if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') {
207
227
  throw error;
208
228
  }
209
229
  if (Date.now() - startedAt > LOCK_MUTEX_WAIT_MS) {
210
- const ownerPath = path.join(mutex, 'owner.json');
211
230
  try {
212
231
  const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
213
232
  const ownerPid = Number(owner.pid);
@@ -220,9 +239,18 @@ function acquireMutex(projectRoot) {
220
239
  }
221
240
  }
222
241
  catch {
223
- rmSync(mutex, { recursive: true, force: true });
224
- startedAt = Date.now();
225
- continue;
242
+ try {
243
+ const mutexStat = statSync(mutex);
244
+ if (Date.now() - mutexStat.mtimeMs > LOCK_MUTEX_STALE_MS) {
245
+ rmSync(mutex, { recursive: true, force: true });
246
+ startedAt = Date.now();
247
+ continue;
248
+ }
249
+ }
250
+ catch {
251
+ startedAt = Date.now();
252
+ continue;
253
+ }
226
254
  }
227
255
  throw new Error('active_run_lock_mutex_busy');
228
256
  }
@@ -1,30 +1,15 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- function tempFilePath(targetPath) {
5
- const suffix = `${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
6
- return path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${suffix}.tmp`);
7
- }
4
+ import { writeUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
8
5
  export function createStateRunId(prefix) {
9
6
  const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
10
7
  return `${prefix}-${timestamp}-${process.pid}-${randomBytes(6).toString('hex')}`;
11
8
  }
12
9
  export function atomicWriteTextFile(targetPath, content) {
13
- mkdirSync(path.dirname(targetPath), { recursive: true });
14
- const temporaryPath = tempFilePath(targetPath);
15
- try {
16
- writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx' });
17
- renameSync(temporaryPath, targetPath);
18
- }
19
- catch (error) {
20
- try {
21
- unlinkSync(temporaryPath);
22
- }
23
- catch {
24
- // Best-effort cleanup for a temporary file that may not have been created.
25
- }
26
- throw error;
27
- }
10
+ const targetDirectory = path.dirname(targetPath);
11
+ mkdirSync(targetDirectory, { recursive: true });
12
+ writeUtf8FileInsideWithoutSymlinks(targetDirectory, targetPath, content);
28
13
  }
29
14
  export function atomicWriteJsonFile(targetPath, value) {
30
15
  atomicWriteTextFile(targetPath, `${JSON.stringify(value, null, 2)}\n`);
@@ -188,11 +188,24 @@ function intentIsExplicitlySubsumed(commandContract, narrowerIntent, broaderInte
188
188
  return (intentExplicitlySubsumedBy(commandContract, narrowerIntent, broaderIntent) ||
189
189
  intentExplicitlySubsumes(commandContract, broaderIntent, narrowerIntent));
190
190
  }
191
+ function minNumber(values) {
192
+ let minimum = null;
193
+ for (const value of values) {
194
+ minimum = minimum === null ? value : Math.min(minimum, value);
195
+ }
196
+ return minimum;
197
+ }
191
198
  function selectVerificationCandidates(commandContract, candidates) {
192
199
  const runnableCandidates = candidates.filter((candidate) => candidate.status === 'runnable' && candidate.intent.length > 0);
193
200
  const selectedIntents = new Set(runnableCandidates.map((candidate) => candidate.intent));
194
201
  for (const candidate of runnableCandidates) {
195
- const isSubsumed = runnableCandidates.some((other) => other.intent !== candidate.intent && intentIsExplicitlySubsumed(commandContract, candidate.intent, other.intent));
202
+ const isSubsumed = runnableCandidates.some((other) => {
203
+ if (other.intent === candidate.intent) {
204
+ return false;
205
+ }
206
+ return (intentIsExplicitlySubsumed(commandContract, candidate.intent, other.intent) &&
207
+ !intentIsExplicitlySubsumed(commandContract, other.intent, candidate.intent));
208
+ });
196
209
  if (isSubsumed) {
197
210
  selectedIntents.delete(candidate.intent);
198
211
  }
@@ -217,7 +230,10 @@ function selectVerificationCandidates(commandContract, candidates) {
217
230
  if (costs.some((cost) => cost === null)) {
218
231
  continue;
219
232
  }
220
- const minCost = Math.min(...costs);
233
+ const minCost = minNumber(costs);
234
+ if (minCost === null) {
235
+ continue;
236
+ }
221
237
  const winners = group.filter((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent) === minCost);
222
238
  if (winners.length !== 1) {
223
239
  continue;
@@ -280,8 +280,8 @@ function pushCoverageFinding(issues, findings, severity, code, reason, intent, i
280
280
  });
281
281
  pushIssue(issues, severity, code, intent, message);
282
282
  }
283
- function configuredIntentIsRunnable(intent) {
284
- return evaluateCommandIntentEligibility('summary', intent).ok;
283
+ function configuredIntentIsRunnable(intentName, intent) {
284
+ return evaluateCommandIntentEligibility(intentName, intent).ok;
285
285
  }
286
286
  function lintIntent(name, value, issues) {
287
287
  if (!commandIntentNameIsSafe(name)) {
@@ -582,7 +582,7 @@ export function lintCommandContract(contract, options = {}) {
582
582
  summary: {
583
583
  totalIntents: intentEntries.length,
584
584
  configured: validIntents.filter((intent) => readString(intent, 'status') === 'configured').length,
585
- runnable: validIntents.filter(configuredIntentIsRunnable).length,
585
+ runnable: intentTables.filter(([name, intent]) => configuredIntentIsRunnable(name, intent)).length,
586
586
  manualOnly: validIntents.filter((intent) => readString(intent, 'status') === 'manual_only').length,
587
587
  unknown: validIntents.filter((intent) => readString(intent, 'status') === 'unknown').length,
588
588
  errors,
@@ -23,6 +23,54 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
23
23
  documented: true,
24
24
  installedCommand: ['mf', 'context', '--json'],
25
25
  },
26
+ {
27
+ id: 'workspace-summary',
28
+ schemaFile: 'workspace-summary.schema.json',
29
+ producer: 'mf api workspace-summary --json',
30
+ packaged: true,
31
+ documented: true,
32
+ installedCommand: ['mf', 'api', 'workspace-summary', '--json'],
33
+ },
34
+ {
35
+ id: 'command-catalog',
36
+ schemaFile: 'command-catalog.schema.json',
37
+ producer: 'mf api command-catalog --json',
38
+ packaged: true,
39
+ documented: true,
40
+ installedCommand: ['mf', 'api', 'command-catalog', '--json'],
41
+ },
42
+ {
43
+ id: 'verification-plan',
44
+ schemaFile: 'verification-plan.schema.json',
45
+ producer: 'mf api verification-plan --changed --json',
46
+ packaged: true,
47
+ documented: true,
48
+ installedCommand: ['mf', 'api', 'verification-plan', '--changed', '--json'],
49
+ },
50
+ {
51
+ id: 'latest-evidence',
52
+ schemaFile: 'latest-evidence.schema.json',
53
+ producer: 'mf api latest-evidence --json',
54
+ packaged: true,
55
+ documented: true,
56
+ installedCommand: ['mf', 'api', 'latest-evidence', '--json'],
57
+ },
58
+ {
59
+ id: 'diff-risk',
60
+ schemaFile: 'diff-risk.schema.json',
61
+ producer: 'mf api diff-risk --changed --json',
62
+ packaged: true,
63
+ documented: true,
64
+ installedCommand: ['mf', 'api', 'diff-risk', '--changed', '--json'],
65
+ },
66
+ {
67
+ id: 'health',
68
+ schemaFile: 'health.schema.json',
69
+ producer: 'mf api health --json',
70
+ packaged: true,
71
+ documented: true,
72
+ installedCommand: ['mf', 'api', 'health', '--json'],
73
+ },
26
74
  {
27
75
  id: 'run-receipt',
28
76
  schemaFile: 'run-receipt.schema.json',
@@ -124,7 +124,7 @@ export function updateRepeatedFailureState(input) {
124
124
  requires_new_evidence: UNRESOLVED_VERIFY_STATUSES.has(input.status) && seenCount >= 2,
125
125
  };
126
126
  const nextFingerprints = [summary, ...state.fingerprints.filter((entry) => entry.fingerprint !== summary.fingerprint)]
127
- .sort((left, right) => right.last_seen_at.localeCompare(left.last_seen_at))
127
+ .sort((left, right) => (left.last_seen_at > right.last_seen_at ? -1 : left.last_seen_at < right.last_seen_at ? 1 : 0))
128
128
  .slice(0, REPEATED_FAILURE_STATE_LIMIT);
129
129
  writeRepeatedFailureState(input.projectRoot, {
130
130
  schema_version: '1',
@@ -4,6 +4,7 @@ import { existsSync, lstatSync, readFileSync, readlinkSync, readdirSync } from '
4
4
  import path from 'node:path';
5
5
  import { normalizeCommandEffects } from './command-effects.js';
6
6
  const MAX_SNAPSHOT_FILES = 20_000;
7
+ const MAX_SNAPSHOT_DIRECTORY_DEPTH = 200;
7
8
  const MAX_REPORTED_PATHS = 200;
8
9
  const GIT_STATUS_TIMEOUT_MS = 10_000;
9
10
  const GIT_STATUS_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
@@ -40,33 +41,41 @@ function signatureForPath(fullPath) {
40
41
  }
41
42
  function signatureForGitStatusPath(projectRoot, relativePath, status) {
42
43
  const fullPath = path.join(projectRoot, ...relativePath.split('/'));
43
- if (!existsSync(fullPath)) {
44
- return `git:${status}:missing`;
45
- }
46
- const stat = lstatSync(fullPath);
47
- if (stat.isSymbolicLink()) {
48
- return `git:${status}:symlink:${readlinkSync(fullPath)}`;
49
- }
50
- if (!stat.isFile()) {
51
- return `git:${status}:${stat.isDirectory() ? 'directory' : 'other'}:${stat.size}:${stat.mtimeMs}`;
44
+ try {
45
+ if (!existsSync(fullPath)) {
46
+ return `git:${status}:missing`;
47
+ }
48
+ const stat = lstatSync(fullPath);
49
+ if (stat.isSymbolicLink()) {
50
+ return `git:${status}:symlink:${readlinkSync(fullPath)}`;
51
+ }
52
+ if (!stat.isFile()) {
53
+ return `git:${status}:${stat.isDirectory() ? 'directory' : 'other'}:${stat.size}:${stat.mtimeMs}`;
54
+ }
55
+ if (stat.size > MAX_HASH_BYTES) {
56
+ return `git:${status}:file:${stat.size}:${stat.mtimeMs}:unhashed`;
57
+ }
58
+ const digest = createHash('sha256').update(readFileSync(fullPath)).digest('hex');
59
+ return `git:${status}:file:${stat.size}:${digest}`;
52
60
  }
53
- if (stat.size > MAX_HASH_BYTES) {
54
- return `git:${status}:file:${stat.size}:${stat.mtimeMs}:unhashed`;
61
+ catch {
62
+ return `git:${status}:missing`;
55
63
  }
56
- const digest = createHash('sha256').update(readFileSync(fullPath)).digest('hex');
57
- return `git:${status}:file:${stat.size}:${digest}`;
58
64
  }
59
- function collectSnapshotEntries(projectRoot, currentPath, entries) {
65
+ function collectSnapshotEntries(currentPath, currentRelativePath, depth, entries) {
66
+ if (depth > MAX_SNAPSHOT_DIRECTORY_DEPTH) {
67
+ throw new Error('snapshot_directory_depth_limit_exceeded');
68
+ }
60
69
  const names = readdirSync(currentPath).sort((left, right) => left.localeCompare(right));
61
70
  for (const name of names) {
62
71
  const fullPath = path.join(currentPath, name);
63
- const relativePath = normalizeRelativePath(path.relative(projectRoot, fullPath));
72
+ const relativePath = currentRelativePath === '.' ? name : `${currentRelativePath}/${name}`;
64
73
  const stat = lstatSync(fullPath);
65
74
  if (stat.isDirectory()) {
66
75
  if (isExcludedDirectory(relativePath, name)) {
67
76
  continue;
68
77
  }
69
- collectSnapshotEntries(projectRoot, fullPath, entries);
78
+ collectSnapshotEntries(fullPath, relativePath, depth + 1, entries);
70
79
  continue;
71
80
  }
72
81
  if (entries.size >= MAX_SNAPSHOT_FILES) {
@@ -90,7 +99,7 @@ function captureSnapshot(projectRoot, env) {
90
99
  }
91
100
  try {
92
101
  const entries = new Map();
93
- collectSnapshotEntries(projectRoot, projectRoot, entries);
102
+ collectSnapshotEntries(projectRoot, '.', 0, entries);
94
103
  return { status: 'checked', entries, reason: null, source: 'recursive_snapshot' };
95
104
  }
96
105
  catch (error) {
@@ -145,6 +154,10 @@ function captureGitStatusSnapshot(projectRoot, env) {
145
154
  }
146
155
  entries.set(filePath, signatureForGitStatusPath(projectRoot, filePath, status));
147
156
  if (status.includes('R') || status.includes('C')) {
157
+ const sourcePath = normalizeRelativePath(parts[index + 1] ?? '');
158
+ if (status.includes('R') && sourcePath.length > 0) {
159
+ entries.set(sourcePath, `git:${status}:missing`);
160
+ }
148
161
  index += 1;
149
162
  }
150
163
  }
@@ -1,7 +1,11 @@
1
- import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
1
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
  const NOFOLLOW_FLAG = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
5
+ const WINDOWS_RENAME_RETRY_DELAYS_MS = [10, 25, 50, 100, 200];
6
+ const WINDOWS_RENAME_RETRY_CODES = new Set(['EBUSY', 'ENOTEMPTY', 'EPERM']);
7
+ const WRITE_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
8
+ const READ_CHUNK_BYTES = 64 * 1024;
5
9
  function isMissingPathError(error) {
6
10
  return error instanceof Error && 'code' in error && error.code === 'ENOENT';
7
11
  }
@@ -9,6 +13,30 @@ function tempFilePath(targetPath) {
9
13
  const suffix = `${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
10
14
  return path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${suffix}.tmp`);
11
15
  }
16
+ function sleep(milliseconds) {
17
+ Atomics.wait(WRITE_SLEEP_BUFFER, 0, 0, milliseconds);
18
+ }
19
+ function isRetryableWindowsRenameError(error) {
20
+ if (process.platform !== 'win32' || !error || typeof error !== 'object' || !('code' in error)) {
21
+ return false;
22
+ }
23
+ return WINDOWS_RENAME_RETRY_CODES.has(String(error.code));
24
+ }
25
+ function renameWithWindowsRetry(sourcePath, targetPath) {
26
+ for (let attempt = 0;; attempt += 1) {
27
+ try {
28
+ renameSync(sourcePath, targetPath);
29
+ return;
30
+ }
31
+ catch (error) {
32
+ const delay = WINDOWS_RENAME_RETRY_DELAYS_MS[attempt];
33
+ if (delay === undefined || !isRetryableWindowsRenameError(error)) {
34
+ throw error;
35
+ }
36
+ sleep(delay);
37
+ }
38
+ }
39
+ }
12
40
  export function ensureInside(parentPath, childPath) {
13
41
  const parent = path.resolve(parentPath);
14
42
  const child = path.resolve(childPath);
@@ -42,13 +70,29 @@ export function ensureInsideWithoutSymlinks(parentPath, childPath, options = {})
42
70
  }
43
71
  }
44
72
  catch (error) {
45
- if (isMissingPathError(error) && options.allowMissingLeaf) {
73
+ if (isMissingPathError(error) && (options.allowMissingDescendant || (isLeaf && options.allowMissingLeaf))) {
46
74
  return;
47
75
  }
48
76
  throw error;
49
77
  }
50
78
  }
51
79
  }
80
+ function readBoundedFileDescriptor(fileDescriptor, childPath, maxBytes) {
81
+ const chunks = [];
82
+ let totalBytes = 0;
83
+ while (true) {
84
+ const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes + 1 - totalBytes));
85
+ const bytesRead = readSync(fileDescriptor, chunk, 0, chunk.byteLength, null);
86
+ if (bytesRead === 0) {
87
+ return Buffer.concat(chunks, totalBytes);
88
+ }
89
+ totalBytes += bytesRead;
90
+ if (totalBytes > maxBytes) {
91
+ throw new Error(`File exceeds maximum size ${maxBytes} bytes: ${childPath}`);
92
+ }
93
+ chunks.push(bytesRead === chunk.byteLength ? chunk : chunk.subarray(0, bytesRead));
94
+ }
95
+ }
52
96
  function ensureDirectoryInsideWithoutSymlinks(parentPath, directoryPath) {
53
97
  ensureInside(parentPath, directoryPath);
54
98
  const parent = path.resolve(parentPath);
@@ -86,7 +130,7 @@ function ensureDirectoryInsideWithoutSymlinks(parentPath, directoryPath) {
86
130
  export function ensureFileTargetInsideWithoutSymlinks(parentPath, childPath, options = {}) {
87
131
  const absoluteChildPath = path.resolve(childPath);
88
132
  ensureInside(parentPath, absoluteChildPath);
89
- ensureInsideWithoutSymlinks(parentPath, path.dirname(absoluteChildPath), { allowMissingLeaf: true });
133
+ ensureInsideWithoutSymlinks(parentPath, path.dirname(absoluteChildPath), { allowMissingDescendant: true });
90
134
  try {
91
135
  const stats = lstatSync(absoluteChildPath);
92
136
  if (stats.isSymbolicLink()) {
@@ -108,6 +152,9 @@ export function readFileInsideWithoutSymlinks(parentPath, childPath, options = {
108
152
  ensureInsideWithoutSymlinks(parentPath, absoluteChildPath);
109
153
  const fileDescriptor = openSync(absoluteChildPath, constants.O_RDONLY | NOFOLLOW_FLAG);
110
154
  try {
155
+ if (NOFOLLOW_FLAG === 0 && lstatSync(absoluteChildPath).isSymbolicLink()) {
156
+ throw new Error(`Path must not contain symlinks: ${childPath}`);
157
+ }
111
158
  const stats = fstatSync(fileDescriptor);
112
159
  if (!stats.isFile()) {
113
160
  throw new Error(`Path must be a regular file: ${childPath}`);
@@ -115,7 +162,9 @@ export function readFileInsideWithoutSymlinks(parentPath, childPath, options = {
115
162
  if (options.maxBytes !== undefined && stats.size > options.maxBytes) {
116
163
  throw new Error(`File exceeds maximum size ${options.maxBytes} bytes: ${childPath}`);
117
164
  }
118
- return readFileSync(fileDescriptor);
165
+ return options.maxBytes === undefined
166
+ ? readFileSync(fileDescriptor)
167
+ : readBoundedFileDescriptor(fileDescriptor, childPath, options.maxBytes);
119
168
  }
120
169
  finally {
121
170
  closeSync(fileDescriptor);
@@ -137,7 +186,7 @@ export function writeFileInsideWithoutSymlinks(parentPath, childPath, content) {
137
186
  closeSync(fileDescriptor);
138
187
  fileDescriptor = null;
139
188
  ensureFileTargetInsideWithoutSymlinks(parentPath, absoluteChildPath, { allowMissingLeaf: true });
140
- renameSync(temporaryPath, absoluteChildPath);
189
+ renameWithWindowsRetry(temporaryPath, absoluteChildPath);
141
190
  }
142
191
  catch (error) {
143
192
  if (fileDescriptor !== null) {
@@ -16,7 +16,8 @@ function readFrontmatterLines(content) {
16
16
  if (!content.startsWith('---')) {
17
17
  return [];
18
18
  }
19
- const end = content.indexOf('\n---', 3);
19
+ const endMatch = /\n---(?:\r?\n|$)/u.exec(content.slice(3));
20
+ const end = endMatch ? 3 + endMatch.index : -1;
20
21
  if (end < 0) {
21
22
  return [];
22
23
  }
@@ -21,6 +21,7 @@ export const SOURCE_ANCHOR_GENERATED_PATH_PARTS = new Set([
21
21
  'vendor',
22
22
  ]);
23
23
  export const SOURCE_ANCHOR_ALLOWED_FIELDS = new Set(['purpose', 'search', 'invariant', 'risk']);
24
+ const MAX_SOURCE_ANCHOR_DIRECTORY_DEPTH = 200;
24
25
  export const SOURCE_ANCHOR_ALLOWED_RISKS = new Set([
25
26
  'authn',
26
27
  'authz',
@@ -79,10 +80,13 @@ function fileIsWithinSizeLimit(filePath, maxFileBytes) {
79
80
  return false;
80
81
  }
81
82
  }
82
- function listFilesRecursive(root, options, current = root) {
83
+ function listFilesRecursive(root, options, current = root, depth = 0) {
83
84
  if (!existsSync(current)) {
84
85
  return [];
85
86
  }
87
+ if (depth > MAX_SOURCE_ANCHOR_DIRECTORY_DEPTH) {
88
+ return [];
89
+ }
86
90
  const currentRealPath = realpathSync(current);
87
91
  if (!pathIsInsideRoot(options.rootRealPath, currentRealPath) || options.visitedRealDirectories.has(currentRealPath)) {
88
92
  return [];
@@ -96,7 +100,7 @@ function listFilesRecursive(root, options, current = root) {
96
100
  continue;
97
101
  }
98
102
  if (entry.isDirectory()) {
99
- files.push(...listFilesRecursive(root, options, entryPath));
103
+ files.push(...listFilesRecursive(root, options, entryPath, depth + 1));
100
104
  continue;
101
105
  }
102
106
  if (entry.isSymbolicLink()) {
@@ -121,7 +125,7 @@ function listFilesRecursive(root, options, current = root) {
121
125
  continue;
122
126
  }
123
127
  if (stat.isDirectory()) {
124
- files.push(...listFilesRecursive(root, options, entryPath));
128
+ files.push(...listFilesRecursive(root, options, entryPath, depth + 1));
125
129
  continue;
126
130
  }
127
131
  if (stat.isFile()) {