mustflow 2.22.13 → 2.22.14

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.
@@ -3,9 +3,11 @@ import { isMustflowBinName } from '../../core/command-classification.js';
3
3
  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
+ import { inspectActiveRunLocks, } from '../../core/active-run-locks.js';
6
7
  import { isRecord, readPositiveInteger, readString, readStringArray, } from '../../core/config-loading.js';
7
8
  import { DEFAULT_COMMAND_MAX_OUTPUT_BYTES, COMMAND_OUTPUT_LIMIT_SCOPE, MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage, } from '../../core/command-output-limits.js';
8
9
  import { normalizeSuccessExitCodes } from '../../core/success-exit-codes.js';
10
+ import { evaluateCommandPreconditions, } from '../../core/command-preconditions.js';
9
11
  import { t } from './i18n.js';
10
12
  function getSuccessExitCodes(intent) {
11
13
  return normalizeSuccessExitCodes(intent.success_exit_codes);
@@ -124,7 +126,7 @@ function readRunIntentMetadata(contract, intent) {
124
126
  relatedOneshotChecks: readStringArray(intent, 'related_oneshot_checks') ?? [],
125
127
  };
126
128
  }
127
- function createBlockedRunPlan(contract, intentName, intent, eligibility, reasonCode, detail) {
129
+ function createBlockedRunPlan(contract, intentName, intent, eligibility, reasonCode, detail, preconditions = []) {
128
130
  const metadata = intent ? readRunIntentMetadata(contract, intent) : null;
129
131
  return {
130
132
  intentName,
@@ -160,6 +162,9 @@ function createBlockedRunPlan(contract, intentName, intent, eligibility, reasonC
160
162
  healthCheckUrl: metadata?.healthCheckUrl ?? null,
161
163
  stopInstruction: metadata?.stopInstruction ?? null,
162
164
  relatedOneshotChecks: metadata?.relatedOneshotChecks ?? [],
165
+ preconditions,
166
+ activeLockConflicts: [],
167
+ staleActiveLocks: [],
163
168
  };
164
169
  }
165
170
  export function createRunPlan(projectRoot, contract, intentName, options = {}) {
@@ -168,25 +173,27 @@ export function createRunPlan(projectRoot, contract, intentName, options = {}) {
168
173
  if (!isRecord(rawIntent)) {
169
174
  return createBlockedRunPlan(contract, intentName, undefined, eligibility, 'intent_not_table', eligibility.detail);
170
175
  }
176
+ const preconditions = evaluateCommandPreconditions(projectRoot, contract, intentName);
177
+ const activeLocks = inspectActiveRunLocks(projectRoot, contract, intentName);
171
178
  if (!eligibility.ok) {
172
- return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, eligibility.code, eligibility.detail);
179
+ return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, eligibility.code, eligibility.detail, preconditions);
173
180
  }
174
181
  const metadata = readRunIntentMetadata(contract, rawIntent);
175
182
  const maxOutputBytesLimitDetail = getMaxOutputBytesLimitDetail(contract, rawIntent);
176
183
  if (maxOutputBytesLimitDetail) {
177
- return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'max_output_bytes_exceeds_limit', maxOutputBytesLimitDetail);
184
+ return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'max_output_bytes_exceeds_limit', maxOutputBytesLimitDetail, preconditions);
178
185
  }
179
186
  let cwd;
180
187
  try {
181
188
  cwd = resolveSafeProjectCwd(projectRoot, metadata.configuredCwd);
182
189
  }
183
190
  catch (error) {
184
- return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'cwd_outside_project', error instanceof Error ? error.message : String(error));
191
+ return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'cwd_outside_project', error instanceof Error ? error.message : String(error), preconditions);
185
192
  }
186
193
  const testTargets = commandAcceptsTestTargets(rawIntent) ? normalizeTestTargets(options.testTargets) : [];
187
194
  const commandArgv = metadata.commandArgv && testTargets.length > 0 ? [...metadata.commandArgv, ...testTargets] : metadata.commandArgv;
188
195
  if (!metadata.timeoutSeconds || !metadata.mode) {
189
- return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, !metadata.timeoutSeconds ? 'missing_timeout' : 'missing_command_source', !metadata.timeoutSeconds ? 'Intent timeout_seconds is missing or invalid.' : 'Intent does not define argv or shell cmd.');
196
+ return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, !metadata.timeoutSeconds ? 'missing_timeout' : 'missing_command_source', !metadata.timeoutSeconds ? 'Intent timeout_seconds is missing or invalid.' : 'Intent does not define argv or shell cmd.', preconditions);
190
197
  }
191
198
  return {
192
199
  intentName,
@@ -222,6 +229,9 @@ export function createRunPlan(projectRoot, contract, intentName, options = {}) {
222
229
  healthCheckUrl: metadata.healthCheckUrl,
223
230
  stopInstruction: metadata.stopInstruction,
224
231
  relatedOneshotChecks: metadata.relatedOneshotChecks,
232
+ preconditions,
233
+ activeLockConflicts: activeLocks.conflicts,
234
+ staleActiveLocks: activeLocks.staleRecords,
225
235
  };
226
236
  }
227
237
  function formatTomlString(value) {
@@ -303,6 +313,9 @@ export function createRunPreview(plan, previewMode) {
303
313
  health_check_url: plan.healthCheckUrl,
304
314
  stop_instruction: plan.stopInstruction,
305
315
  related_oneshot_checks: plan.relatedOneshotChecks,
316
+ preconditions: plan.preconditions,
317
+ active_lock_conflicts: plan.activeLockConflicts,
318
+ stale_active_locks: plan.staleActiveLocks,
306
319
  };
307
320
  }
308
321
  export function renderRunPreviewText(plan, previewMode, lang = 'en') {
@@ -334,6 +347,19 @@ export function renderRunPreviewText(plan, previewMode, lang = 'en') {
334
347
  lines.push(`Cwd: ${plan.relativeCwd}`);
335
348
  lines.push(`Timeout: ${plan.timeoutSeconds}s`);
336
349
  lines.push(`Environment: ${plan.envPolicy}${plan.envAllowlist.length > 0 ? ` (${plan.envAllowlist.join(', ')})` : ''}`);
350
+ if (plan.preconditions.length > 0) {
351
+ lines.push('Preconditions:');
352
+ for (const precondition of plan.preconditions) {
353
+ const target = precondition.path ?? precondition.artifact ?? precondition.label ?? precondition.kind;
354
+ lines.push(`- ${precondition.kind} ${target}: ${precondition.status}`);
355
+ }
356
+ }
357
+ if (plan.activeLockConflicts.length > 0) {
358
+ lines.push('Active lock conflicts:');
359
+ for (const conflict of plan.activeLockConflicts) {
360
+ lines.push(`- ${conflict.lock}: ${conflict.detail}`);
361
+ }
362
+ }
337
363
  if (plan.commandArgv) {
338
364
  lines.push(`Argv: ${plan.commandArgv.join(' ')}`);
339
365
  }
@@ -0,0 +1,294 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { commandEffectsConflict, normalizeCommandEffects, } from './command-effects.js';
5
+ const ACTIVE_LOCK_SCHEMA_VERSION = '1';
6
+ const ACTIVE_LOCK_KIND = 'active_run_lock';
7
+ const LOCK_ROOT_RELATIVE_PATH = '.mustflow/state/locks';
8
+ const LOCK_MUTEX_STALE_MS = 30_000;
9
+ const LOCK_MUTEX_WAIT_MS = 1_000;
10
+ const LOCK_MUTEX_SLEEP_MS = 25;
11
+ function sleep(milliseconds) {
12
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
13
+ }
14
+ function sha256(value) {
15
+ return createHash('sha256').update(value).digest('hex');
16
+ }
17
+ function activeLockRoot(projectRoot) {
18
+ return path.join(projectRoot, ...LOCK_ROOT_RELATIVE_PATH.split('/'));
19
+ }
20
+ function activeLockDirectory(projectRoot) {
21
+ return path.join(activeLockRoot(projectRoot), 'active');
22
+ }
23
+ function activeLockMutexDirectory(projectRoot) {
24
+ return path.join(activeLockRoot(projectRoot), 'mutex');
25
+ }
26
+ function normalizeEffect(effect) {
27
+ return {
28
+ source: effect.source,
29
+ access: effect.access,
30
+ mode: effect.mode,
31
+ path: effect.path,
32
+ lock: effect.lock,
33
+ concurrency: effect.concurrency,
34
+ };
35
+ }
36
+ function isProcessLive(pid) {
37
+ if (!Number.isInteger(pid) || pid <= 0) {
38
+ return false;
39
+ }
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ }
44
+ catch (error) {
45
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
46
+ return code === 'EPERM';
47
+ }
48
+ }
49
+ function commandEffectsFromRecord(record) {
50
+ return record.effects.map((effect) => ({
51
+ intent: record.intent,
52
+ source: effect.source === 'writes' ? 'writes' : 'effects',
53
+ access: effect.access === 'read' ? 'read' : 'write',
54
+ mode: effect.mode === 'read' ? 'read' :
55
+ effect.mode === 'append' ? 'append' :
56
+ effect.mode === 'replace' ? 'replace' :
57
+ effect.mode === 'delete_recreate' ? 'delete_recreate' :
58
+ 'write',
59
+ path: effect.path,
60
+ lock: effect.lock,
61
+ concurrency: effect.concurrency === 'shared' ? 'shared' : 'exclusive',
62
+ }));
63
+ }
64
+ function activeLockRecordPath(projectRoot, runId) {
65
+ return path.join(activeLockDirectory(projectRoot), `${sha256(runId)}.json`);
66
+ }
67
+ function parseRecord(value) {
68
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
69
+ return null;
70
+ }
71
+ const record = value;
72
+ if (record.schema_version !== ACTIVE_LOCK_SCHEMA_VERSION ||
73
+ record.kind !== ACTIVE_LOCK_KIND ||
74
+ typeof record.run_id !== 'string' ||
75
+ typeof record.intent !== 'string' ||
76
+ !Number.isInteger(record.pid) ||
77
+ typeof record.started_at !== 'string' ||
78
+ typeof record.root_hash !== 'string' ||
79
+ !(typeof record.command_hash === 'string' || record.command_hash === null) ||
80
+ !Array.isArray(record.effects) ||
81
+ !Array.isArray(record.writes)) {
82
+ return null;
83
+ }
84
+ const effects = record.effects.filter((effect) => {
85
+ if (!effect || typeof effect !== 'object' || Array.isArray(effect)) {
86
+ return false;
87
+ }
88
+ const candidate = effect;
89
+ return (typeof candidate.source === 'string' &&
90
+ typeof candidate.access === 'string' &&
91
+ typeof candidate.mode === 'string' &&
92
+ (typeof candidate.path === 'string' || candidate.path === null) &&
93
+ typeof candidate.lock === 'string' &&
94
+ typeof candidate.concurrency === 'string');
95
+ });
96
+ const writes = record.writes.filter((write) => typeof write === 'string');
97
+ if (effects.length !== record.effects.length || writes.length !== record.writes.length) {
98
+ return null;
99
+ }
100
+ return {
101
+ schema_version: ACTIVE_LOCK_SCHEMA_VERSION,
102
+ kind: ACTIVE_LOCK_KIND,
103
+ run_id: record.run_id,
104
+ intent: record.intent,
105
+ pid: Number(record.pid),
106
+ started_at: record.started_at,
107
+ root_hash: record.root_hash,
108
+ command_hash: record.command_hash,
109
+ effects,
110
+ writes,
111
+ };
112
+ }
113
+ function readActiveRecords(projectRoot) {
114
+ const directory = activeLockDirectory(projectRoot);
115
+ if (!existsSync(directory)) {
116
+ return [];
117
+ }
118
+ const records = [];
119
+ for (const name of readdirSync(directory).filter((entry) => entry.endsWith('.json')).sort()) {
120
+ try {
121
+ const record = parseRecord(JSON.parse(readFileSync(path.join(directory, name), 'utf8')));
122
+ if (record) {
123
+ records.push(record);
124
+ }
125
+ }
126
+ catch {
127
+ // Unreadable lock records are ignored until a later cleanup can inspect them safely.
128
+ }
129
+ }
130
+ return records;
131
+ }
132
+ function staleRecordFor(record) {
133
+ if (isProcessLive(record.pid)) {
134
+ return null;
135
+ }
136
+ return {
137
+ runId: record.run_id,
138
+ intent: record.intent,
139
+ pid: record.pid,
140
+ reason: 'process_not_live',
141
+ };
142
+ }
143
+ function removeRecord(projectRoot, record) {
144
+ rmSync(activeLockRecordPath(projectRoot, record.run_id), { force: true });
145
+ }
146
+ function conflictDetail(current, active) {
147
+ return `lock "${current.lock}" conflicts with active intent "${active.intent}"`;
148
+ }
149
+ function findConflicts(intentName, effects, records) {
150
+ const conflicts = [];
151
+ for (const record of records) {
152
+ for (const activeEffect of commandEffectsFromRecord(record)) {
153
+ for (const effect of effects) {
154
+ if (!commandEffectsConflict(effect, activeEffect)) {
155
+ continue;
156
+ }
157
+ conflicts.push({
158
+ intent: intentName,
159
+ pid: process.pid,
160
+ lock: effect.lock,
161
+ path: effect.path,
162
+ mode: effect.mode,
163
+ concurrency: effect.concurrency,
164
+ conflictsWithIntent: record.intent,
165
+ conflictsWithPid: record.pid,
166
+ conflictsWithMode: activeEffect.mode,
167
+ detail: conflictDetail(effect, activeEffect),
168
+ });
169
+ }
170
+ }
171
+ }
172
+ return conflicts;
173
+ }
174
+ function createRecord(projectRoot, intentName, effects, commandHash) {
175
+ const startedAt = new Date().toISOString();
176
+ const writes = effects
177
+ .filter((effect) => effect.access === 'write' && effect.path !== null)
178
+ .map((effect) => effect.path)
179
+ .sort((left, right) => left.localeCompare(right));
180
+ const runId = `${process.pid}:${startedAt}:${intentName}:${sha256(JSON.stringify(effects))}`;
181
+ return {
182
+ schema_version: ACTIVE_LOCK_SCHEMA_VERSION,
183
+ kind: ACTIVE_LOCK_KIND,
184
+ run_id: runId,
185
+ intent: intentName,
186
+ pid: process.pid,
187
+ started_at: startedAt,
188
+ root_hash: sha256(path.resolve(projectRoot)),
189
+ command_hash: commandHash,
190
+ effects: effects.map(normalizeEffect),
191
+ writes: [...new Set(writes)],
192
+ };
193
+ }
194
+ function acquireMutex(projectRoot) {
195
+ const root = activeLockRoot(projectRoot);
196
+ const mutex = activeLockMutexDirectory(projectRoot);
197
+ mkdirSync(root, { recursive: true });
198
+ const startedAt = Date.now();
199
+ while (true) {
200
+ try {
201
+ 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 });
204
+ }
205
+ catch (error) {
206
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') {
207
+ throw error;
208
+ }
209
+ if (Date.now() - startedAt > LOCK_MUTEX_WAIT_MS) {
210
+ const ownerPath = path.join(mutex, 'owner.json');
211
+ try {
212
+ const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
213
+ const ownerPid = Number(owner.pid);
214
+ const ownerStartedAt = typeof owner.started_at === 'string' ? Date.parse(owner.started_at) : Number.NaN;
215
+ const staleByAge = Number.isFinite(ownerStartedAt) && Date.now() - ownerStartedAt > LOCK_MUTEX_STALE_MS;
216
+ if (!isProcessLive(ownerPid) || staleByAge) {
217
+ rmSync(mutex, { recursive: true, force: true });
218
+ continue;
219
+ }
220
+ }
221
+ catch {
222
+ rmSync(mutex, { recursive: true, force: true });
223
+ continue;
224
+ }
225
+ throw new Error('active_run_lock_mutex_busy');
226
+ }
227
+ sleep(LOCK_MUTEX_SLEEP_MS);
228
+ }
229
+ }
230
+ }
231
+ export function inspectActiveRunLocks(projectRoot, contract, intentName) {
232
+ const effects = normalizeCommandEffects(projectRoot, contract, intentName);
233
+ const records = readActiveRecords(projectRoot);
234
+ const staleRecords = records.map(staleRecordFor).filter((record) => record !== null);
235
+ const liveRecords = records.filter((record) => !staleRecords.some((stale) => stale.runId === record.run_id));
236
+ return {
237
+ conflicts: findConflicts(intentName, effects, liveRecords),
238
+ staleRecords,
239
+ };
240
+ }
241
+ export function acquireActiveRunLock(projectRoot, contract, intentName, options = {}) {
242
+ const effects = normalizeCommandEffects(projectRoot, contract, intentName);
243
+ if (effects.length === 0) {
244
+ const emptyRecord = createRecord(projectRoot, intentName, [], options.commandHash ?? null);
245
+ return {
246
+ ok: true,
247
+ handle: {
248
+ record: emptyRecord,
249
+ recoveredStaleRecords: [],
250
+ release() {
251
+ // No declared effects means no active lock record was written.
252
+ },
253
+ },
254
+ };
255
+ }
256
+ mkdirSync(activeLockDirectory(projectRoot), { recursive: true });
257
+ const releaseMutex = acquireMutex(projectRoot);
258
+ try {
259
+ const records = readActiveRecords(projectRoot);
260
+ const staleRecords = records.map(staleRecordFor).filter((record) => record !== null);
261
+ for (const stale of staleRecords) {
262
+ const staleRecord = records.find((record) => record.run_id === stale.runId);
263
+ if (staleRecord) {
264
+ removeRecord(projectRoot, staleRecord);
265
+ }
266
+ }
267
+ const liveRecords = records.filter((record) => !staleRecords.some((stale) => stale.runId === record.run_id));
268
+ const conflicts = findConflicts(intentName, effects, liveRecords);
269
+ if (conflicts.length > 0) {
270
+ return { ok: false, conflicts, recoveredStaleRecords: staleRecords };
271
+ }
272
+ const record = createRecord(projectRoot, intentName, effects, options.commandHash ?? null);
273
+ const recordPath = activeLockRecordPath(projectRoot, record.run_id);
274
+ writeFileSync(recordPath, JSON.stringify(record, null, 2));
275
+ let released = false;
276
+ return {
277
+ ok: true,
278
+ handle: {
279
+ record,
280
+ recoveredStaleRecords: staleRecords,
281
+ release() {
282
+ if (released) {
283
+ return;
284
+ }
285
+ released = true;
286
+ rmSync(recordPath, { force: true });
287
+ },
288
+ },
289
+ };
290
+ }
291
+ finally {
292
+ releaseMutex();
293
+ }
294
+ }
@@ -15,6 +15,14 @@ const CHECK_ISSUE_ID_RULES = [
15
15
  'mustflow.command_contract.success_exit_codes_invalid',
16
16
  /^\[commands\.intents\.[^\]]+\]\.success_exit_codes must be a non-empty integer array with values from 0 through 255$/u,
17
17
  ],
18
+ [
19
+ 'mustflow.command_contract.inputs_invalid',
20
+ /^(?:Configured intent [^\s]+ must not declare inputs|\[commands\.intents\..+\.inputs(?:\.|\])|Command input [^\s]+ name must|Command argv token "[^"]+" (?:references undeclared input|must use a whole-token typed input placeholder))/u,
21
+ ],
22
+ [
23
+ 'mustflow.command_contract.preconditions_invalid',
24
+ /^\[commands\.intents\..+\.preconditions(?:\.|\]|\s)/u,
25
+ ],
18
26
  ['mustflow.command_contract.effects_invalid', /^(?:Strict: )?(?:\[commands\.(?:resources|intents\.[^\]]+\.effects)[^\]]*\]|Command effect for intent [^\s]+ must define path, paths, or lock)/u],
19
27
  ['mustflow.command_contract.effect_path_escape', /^Strict: Command effect path must stay inside the current root:/u],
20
28
  ['mustflow.command_contract.shared_writes_without_effects', /^Strict warning: configured agent-runnable intents .+ share path:.+ through writes without explicit effects or resource locks$/u],
@@ -3,9 +3,16 @@ import path from 'node:path';
3
3
  import { COMMAND_LIFECYCLES, COMMAND_RUN_POLICIES, LONG_RUNNING_LIFECYCLES, isRecord, readStringArray, } from './config-loading.js';
4
4
  import { COMMAND_ENV_POLICIES, DEFAULT_COMMAND_ENV_POLICY } from './command-env.js';
5
5
  import { COMMAND_EFFECT_CONCURRENCY, COMMAND_EFFECT_MODES, COMMAND_EFFECT_TYPES, validateCommandEffectLockWarnings, validateCommandEffects, } from './command-effects.js';
6
+ import { COMMAND_PRECONDITION_KINDS } from './command-preconditions.js';
6
7
  import { commandIntentBlockedCommandPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
7
8
  import { MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage } from './command-output-limits.js';
8
9
  import { SUCCESS_EXIT_CODES_CONTRACT_DESCRIPTION, successExitCodesAreValid } from './success-exit-codes.js';
10
+ const COMMAND_INPUT_TYPES = new Set(['path', 'enum', 'boolean', 'integer', 'literal']);
11
+ const COMMAND_INPUT_NAME_PATTERN = /^[a-z][a-z0-9_]*$/u;
12
+ const COMMAND_ARGV_PLACEHOLDER_PATTERN = /^\{([a-z][a-z0-9_]*)\}$/u;
13
+ const COMMAND_ARGV_MIXED_PLACEHOLDER_PATTERN = /\{([a-z][a-z0-9_]*)\}/u;
14
+ const WINDOWS_RESERVED_PATH_SEGMENTS = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu;
15
+ const SAFE_PATH_EXTENSION_PATTERN = /^\.[A-Za-z0-9][A-Za-z0-9._-]*$/u;
9
16
  function commandContractIssue(message, id) {
10
17
  return id ? { id, message } : { message };
11
18
  }
@@ -18,6 +25,9 @@ function hasOwn(table, key) {
18
25
  function isPositiveInteger(value) {
19
26
  return Number.isInteger(value) && Number(value) > 0;
20
27
  }
28
+ function isInteger(value) {
29
+ return Number.isInteger(value);
30
+ }
21
31
  function validateTable(config, tableName, issues) {
22
32
  if (!hasOwn(config, tableName)) {
23
33
  return undefined;
@@ -53,6 +63,11 @@ function validatePositiveIntegerField(table, key, label, issues) {
53
63
  issues.push(commandContractIssue(`${label} must be a positive integer`));
54
64
  }
55
65
  }
66
+ function validateIntegerField(table, key, label, issues, id) {
67
+ if (hasOwn(table, key) && !isInteger(table[key])) {
68
+ issues.push(commandContractIssue(`${label} must be an integer`, id));
69
+ }
70
+ }
56
71
  function validateMaxOutputBytesField(table, key, label, issues) {
57
72
  validatePositiveIntegerField(table, key, label, issues);
58
73
  if (isPositiveInteger(table[key]) && Number(table[key]) > MAX_COMMAND_OUTPUT_BYTES) {
@@ -67,6 +82,166 @@ function validateAllowedStringField(table, key, label, allowedValues, issues) {
67
82
  issues.push(commandContractIssue(`${label} must be ${Array.from(allowedValues).map((value) => `"${value}"`).join(' or ')}`));
68
83
  }
69
84
  }
85
+ function normalizeContractPath(value) {
86
+ return value.trim().replace(/\\/gu, '/');
87
+ }
88
+ function contractPathIsUnsafe(value) {
89
+ const normalized = normalizeContractPath(value);
90
+ const segments = normalized.split('/').filter((segment) => segment.length > 0);
91
+ return (normalized.length === 0 ||
92
+ normalized.includes('\0') ||
93
+ normalized.startsWith('/') ||
94
+ path.win32.isAbsolute(value) ||
95
+ path.posix.isAbsolute(value) ||
96
+ segments.some((segment) => segment === '.' || segment === '..' || WINDOWS_RESERVED_PATH_SEGMENTS.test(segment)));
97
+ }
98
+ function validateSafeRelativePathArrayField(table, key, label, issues) {
99
+ validateStringArrayField(table, key, label, issues);
100
+ if (!Array.isArray(table[key])) {
101
+ return;
102
+ }
103
+ for (const entry of table[key]) {
104
+ if (typeof entry === 'string' && contractPathIsUnsafe(entry)) {
105
+ issues.push(commandContractIssue(`${label} entry "${entry}" must be a normalized repository-relative path without traversal or reserved device names`, 'mustflow.command_contract.inputs_invalid'));
106
+ }
107
+ }
108
+ }
109
+ function validateAllowedExtensionsField(table, key, label, issues) {
110
+ validateStringArrayField(table, key, label, issues);
111
+ if (!Array.isArray(table[key])) {
112
+ return;
113
+ }
114
+ for (const entry of table[key]) {
115
+ if (typeof entry === 'string' && !SAFE_PATH_EXTENSION_PATTERN.test(entry)) {
116
+ issues.push(commandContractIssue(`${label} entry "${entry}" must start with "." and contain only letters, numbers, dots, underscores, or hyphens`, 'mustflow.command_contract.inputs_invalid'));
117
+ }
118
+ }
119
+ }
120
+ function validateCommandInputDefinition(intentName, inputName, input, issues) {
121
+ const label = `[commands.intents.${intentName}.inputs.${inputName}]`;
122
+ if (!COMMAND_INPUT_NAME_PATTERN.test(inputName)) {
123
+ issues.push(commandContractIssue(`Command input ${intentName}.${inputName} name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores`, 'mustflow.command_contract.inputs_invalid'));
124
+ }
125
+ validateAllowedStringField(input, 'type', `${label}.type`, COMMAND_INPUT_TYPES, issues);
126
+ validateBooleanField(input, 'required', `${label}.required`, issues);
127
+ validateBooleanField(input, 'secret', `${label}.secret`, issues);
128
+ validateStringField(input, 'description', `${label}.description`, issues);
129
+ validateStringField(input, 'placeholder', `${label}.placeholder`, issues);
130
+ validateStringArrayField(input, 'allowed_values', `${label}.allowed_values`, issues);
131
+ validateSafeRelativePathArrayField(input, 'allowed_roots', `${label}.allowed_roots`, issues);
132
+ validateAllowedExtensionsField(input, 'allowed_extensions', `${label}.allowed_extensions`, issues);
133
+ validateIntegerField(input, 'min', `${label}.min`, issues, 'mustflow.command_contract.inputs_invalid');
134
+ validateIntegerField(input, 'max', `${label}.max`, issues, 'mustflow.command_contract.inputs_invalid');
135
+ if (input.type === 'path' && (!Array.isArray(input.allowed_roots) || input.allowed_roots.length === 0)) {
136
+ issues.push(commandContractIssue(`${label}.allowed_roots must define at least one repository-relative root for path inputs`, 'mustflow.command_contract.inputs_invalid'));
137
+ }
138
+ if (input.type === 'enum' && (!Array.isArray(input.allowed_values) || input.allowed_values.length === 0)) {
139
+ issues.push(commandContractIssue(`${label}.allowed_values must define at least one value for enum inputs`, 'mustflow.command_contract.inputs_invalid'));
140
+ }
141
+ if (input.type === 'literal' && !hasOwn(input, 'value')) {
142
+ issues.push(commandContractIssue(`${label}.value must be defined for literal inputs`, 'mustflow.command_contract.inputs_invalid'));
143
+ }
144
+ if (hasOwn(input, 'value') && !['string', 'number', 'boolean'].includes(typeof input.value)) {
145
+ issues.push(commandContractIssue(`${label}.value must be a string, number, or boolean`, 'mustflow.command_contract.inputs_invalid'));
146
+ }
147
+ if (isInteger(input.min) && isInteger(input.max) && Number(input.min) > Number(input.max)) {
148
+ issues.push(commandContractIssue(`${label}.min must be less than or equal to ${label}.max`, 'mustflow.command_contract.inputs_invalid'));
149
+ }
150
+ }
151
+ function validateCommandIntentInputs(intentName, intent, issues) {
152
+ if (!hasOwn(intent, 'inputs')) {
153
+ return;
154
+ }
155
+ if (!isRecord(intent.inputs)) {
156
+ issues.push(commandContractIssue(`[commands.intents.${intentName}.inputs] must be a TOML table`, 'mustflow.command_contract.inputs_invalid'));
157
+ return;
158
+ }
159
+ if (intent.status === 'configured') {
160
+ issues.push(commandContractIssue(`Configured intent ${intentName} must not declare inputs until typed input execution is implemented`, 'mustflow.command_contract.inputs_invalid'));
161
+ }
162
+ if (intent.mode === 'shell' || hasOwn(intent, 'cmd')) {
163
+ issues.push(commandContractIssue(`[commands.intents.${intentName}.inputs] requires argv command mode; shell-string interpolation is not allowed`, 'mustflow.command_contract.inputs_invalid'));
164
+ }
165
+ if (!Array.isArray(intent.argv)) {
166
+ issues.push(commandContractIssue(`[commands.intents.${intentName}.inputs] requires argv so typed input placeholders remain argument-bound`, 'mustflow.command_contract.inputs_invalid'));
167
+ }
168
+ const inputNames = new Set();
169
+ for (const [inputName, input] of Object.entries(intent.inputs)) {
170
+ inputNames.add(inputName);
171
+ if (!isRecord(input)) {
172
+ issues.push(commandContractIssue(`[commands.intents.${intentName}.inputs.${inputName}] must be a TOML table`, 'mustflow.command_contract.inputs_invalid'));
173
+ continue;
174
+ }
175
+ validateCommandInputDefinition(intentName, inputName, input, issues);
176
+ }
177
+ const argv = readStringArray(intent, 'argv');
178
+ if (!argv) {
179
+ return;
180
+ }
181
+ for (const token of argv) {
182
+ const exactMatch = COMMAND_ARGV_PLACEHOLDER_PATTERN.exec(token);
183
+ if (exactMatch) {
184
+ const inputName = exactMatch[1];
185
+ if (!inputNames.has(inputName)) {
186
+ issues.push(commandContractIssue(`Command argv token "${token}" references undeclared input ${intentName}.${inputName}`, 'mustflow.command_contract.inputs_invalid'));
187
+ }
188
+ continue;
189
+ }
190
+ if (COMMAND_ARGV_MIXED_PLACEHOLDER_PATTERN.test(token)) {
191
+ issues.push(commandContractIssue(`Command argv token "${token}" must use a whole-token typed input placeholder instead of string interpolation`, 'mustflow.command_contract.inputs_invalid'));
192
+ }
193
+ }
194
+ }
195
+ function validateSafeRelativePathField(table, key, label, issues) {
196
+ validateStringField(table, key, label, issues);
197
+ if (typeof table[key] === 'string' && contractPathIsUnsafe(table[key])) {
198
+ issues.push(commandContractIssue(`${label} must be a normalized repository-relative path without traversal or reserved device names`, 'mustflow.command_contract.preconditions_invalid'));
199
+ }
200
+ }
201
+ function validateSafeRelativePathPatternArrayField(table, key, label, issues) {
202
+ validateStringArrayField(table, key, label, issues);
203
+ if (!Array.isArray(table[key])) {
204
+ return;
205
+ }
206
+ for (const entry of table[key]) {
207
+ if (typeof entry === 'string' && contractPathIsUnsafe(entry.replace(/\*/gu, 'wildcard'))) {
208
+ issues.push(commandContractIssue(`${label} entry "${entry}" must be a normalized repository-relative path pattern without traversal or reserved device names`, 'mustflow.command_contract.preconditions_invalid'));
209
+ }
210
+ }
211
+ }
212
+ function validateCommandIntentPreconditions(intentName, intent, allIntents, issues) {
213
+ if (!hasOwn(intent, 'preconditions')) {
214
+ return;
215
+ }
216
+ if (!Array.isArray(intent.preconditions) || intent.preconditions.some((entry) => !isRecord(entry))) {
217
+ issues.push(commandContractIssue(`[commands.intents.${intentName}].preconditions must be an array of tables`, 'mustflow.command_contract.preconditions_invalid'));
218
+ return;
219
+ }
220
+ for (const [index, precondition] of intent.preconditions.entries()) {
221
+ const preconditionTable = precondition;
222
+ const label = `[commands.intents.${intentName}.preconditions.${index}]`;
223
+ validateAllowedStringField(preconditionTable, 'kind', `${label}.kind`, COMMAND_PRECONDITION_KINDS, issues);
224
+ validateStringField(preconditionTable, 'label', `${label}.label`, issues);
225
+ validateSafeRelativePathField(preconditionTable, 'path', `${label}.path`, issues);
226
+ validateSafeRelativePathField(preconditionTable, 'artifact', `${label}.artifact`, issues);
227
+ validateSafeRelativePathPatternArrayField(preconditionTable, 'sources', `${label}.sources`, issues);
228
+ validateStringField(preconditionTable, 'satisfy_intent', `${label}.satisfy_intent`, issues);
229
+ if (preconditionTable.kind === 'path_exists' && !hasOwn(preconditionTable, 'path')) {
230
+ issues.push(commandContractIssue(`${label}.path is required for kind = "path_exists"`, 'mustflow.command_contract.preconditions_invalid'));
231
+ }
232
+ if (preconditionTable.kind === 'artifact_freshness') {
233
+ if (!hasOwn(preconditionTable, 'artifact')) {
234
+ issues.push(commandContractIssue(`${label}.artifact is required for kind = "artifact_freshness"`, 'mustflow.command_contract.preconditions_invalid'));
235
+ }
236
+ if (!Array.isArray(preconditionTable.sources) || preconditionTable.sources.length === 0) {
237
+ issues.push(commandContractIssue(`${label}.sources must define at least one source path pattern for kind = "artifact_freshness"`, 'mustflow.command_contract.preconditions_invalid'));
238
+ }
239
+ }
240
+ if (typeof preconditionTable.satisfy_intent === 'string' && !isRecord(allIntents[preconditionTable.satisfy_intent])) {
241
+ issues.push(commandContractIssue(`${label}.satisfy_intent references unknown intent "${preconditionTable.satisfy_intent}"`, 'mustflow.command_contract.preconditions_invalid'));
242
+ }
243
+ }
244
+ }
70
245
  function validateCommandDefaults(commandsToml, issues) {
71
246
  const defaults = validateTable(commandsToml, 'defaults', issues);
72
247
  if (!defaults) {
@@ -152,7 +327,7 @@ function validateCommandIntentSelection(intentName, intent, issues) {
152
327
  issues.push(commandContractIssue(`[commands.intents.${intentName}.selection].accepts_test_targets requires argv command mode`));
153
328
  }
154
329
  }
155
- function validateCommandIntent(intentName, intent, issues) {
330
+ function validateCommandIntent(intentName, intent, allIntents, issues) {
156
331
  if (!commandIntentNameIsSafe(intentName)) {
157
332
  issues.push(commandContractIssue(`Intent ${intentName} name must contain only letters, numbers, underscores, and hyphens`));
158
333
  }
@@ -165,6 +340,8 @@ function validateCommandIntent(intentName, intent, issues) {
165
340
  validateMaxOutputBytesField(intent, 'max_output_bytes', `[commands.intents.${intentName}].max_output_bytes`, issues);
166
341
  validatePositiveIntegerField(intent, 'kill_after_seconds', `[commands.intents.${intentName}].kill_after_seconds`, issues);
167
342
  validateCommandIntentSelection(intentName, intent, issues);
343
+ validateCommandIntentInputs(intentName, intent, issues);
344
+ validateCommandIntentPreconditions(intentName, intent, allIntents, issues);
168
345
  if (intent.status !== 'configured') {
169
346
  return;
170
347
  }
@@ -346,7 +523,7 @@ export function validateCommandContractConfig(commandsToml) {
346
523
  issues.push(commandContractIssue(`Intent ${intentName} must be a TOML table`, 'mustflow.command_contract.intent_not_table'));
347
524
  continue;
348
525
  }
349
- validateCommandIntent(intentName, intent, issues);
526
+ validateCommandIntent(intentName, intent, commandsToml.intents, issues);
350
527
  }
351
528
  return issues;
352
529
  }