mustflow 2.22.12 → 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.
- package/README.md +1 -1
- package/dist/cli/commands/explain-verify.js +8 -1
- package/dist/cli/commands/explain.js +269 -2
- package/dist/cli/commands/run.js +109 -77
- package/dist/cli/commands/verify.js +16 -1
- package/dist/cli/lib/run-plan.js +31 -5
- package/dist/core/active-run-locks.js +294 -0
- package/dist/core/check-issues.js +8 -0
- package/dist/core/command-contract-validation.js +179 -2
- package/dist/core/command-explanation.js +5 -3
- package/dist/core/command-preconditions.js +261 -0
- package/dist/core/skill-route-explanation.js +115 -9
- package/package.json +13 -12
- package/schemas/README.md +6 -3
- package/schemas/change-verification-report.schema.json +52 -0
- package/schemas/commands.schema.json +41 -0
- package/schemas/explain-report.schema.json +152 -4
- package/templates/default/i18n.toml +14 -14
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +1 -1
- package/templates/default/locales/en/.mustflow/skills/codebase-orientation/SKILL.md +18 -7
- package/templates/default/locales/en/.mustflow/skills/contract-sync-check/SKILL.md +14 -7
- package/templates/default/locales/en/.mustflow/skills/diff-risk-review/SKILL.md +10 -5
- package/templates/default/locales/en/.mustflow/skills/docs-update/SKILL.md +12 -5
- package/templates/default/locales/en/.mustflow/skills/failure-triage/SKILL.md +26 -5
- package/templates/default/locales/en/.mustflow/skills/pattern-scout/SKILL.md +13 -7
- package/templates/default/locales/en/.mustflow/skills/performance-budget-check/SKILL.md +53 -181
- package/templates/default/locales/en/.mustflow/skills/readme-authoring/SKILL.md +8 -2
- package/templates/default/locales/en/.mustflow/skills/release-notes-authoring/SKILL.md +4 -1
- package/templates/default/locales/en/.mustflow/skills/repro-first-debug/SKILL.md +15 -8
- package/templates/default/locales/en/.mustflow/skills/requirement-regression-guard/SKILL.md +10 -5
- package/templates/default/locales/en/.mustflow/skills/source-freshness-check/SKILL.md +5 -1
- package/templates/default/locales/en/.mustflow/skills/structure-discovery-gate/SKILL.md +2 -2
- package/templates/default/locales/en/.mustflow/skills/test-maintenance/SKILL.md +17 -4
- package/templates/default/manifest.toml +1 -1
package/dist/cli/lib/run-plan.js
CHANGED
|
@@ -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
|
}
|