arkgate 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -2
  3. package/SECURITY.md +3 -4
  4. package/bin/ark-check.mjs +41 -16
  5. package/bin/ark-mcp.mjs +54 -10
  6. package/bin/ark.mjs +35 -5
  7. package/bin/lib/agent-gates.mjs +9 -5
  8. package/bin/lib/architecture-scan.mjs +4 -1
  9. package/bin/lib/baseline-key.mjs +17 -0
  10. package/bin/lib/config-warnings.mjs +22 -0
  11. package/bin/lib/core-layers.mjs +7 -0
  12. package/bin/lib/core-ratchet.mjs +3 -7
  13. package/bin/lib/doctor-plan.mjs +29 -5
  14. package/bin/lib/safety-diagnostics.mjs +263 -0
  15. package/bin/lib/scan-files.mjs +51 -6
  16. package/bin/lib/violations.mjs +3 -3
  17. package/dist/index.cjs +103 -8
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +5 -3
  20. package/dist/index.d.ts +5 -3
  21. package/dist/index.js +103 -8
  22. package/dist/index.js.map +1 -1
  23. package/dist/nestjs/index.cjs +18 -5
  24. package/dist/nestjs/index.cjs.map +1 -1
  25. package/dist/nestjs/index.d.cts +1 -1
  26. package/dist/nestjs/index.d.ts +1 -1
  27. package/dist/nestjs/index.js +18 -5
  28. package/dist/nestjs/index.js.map +1 -1
  29. package/dist/runtime/index.cjs +103 -8
  30. package/dist/runtime/index.cjs.map +1 -1
  31. package/dist/runtime/index.d.cts +1 -1
  32. package/dist/runtime/index.d.ts +1 -1
  33. package/dist/runtime/index.js +103 -8
  34. package/dist/runtime/index.js.map +1 -1
  35. package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
  36. package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
  37. package/docs/agent-guide.md +1 -1
  38. package/docs/ai-gates.md +20 -2
  39. package/docs/package-surface.md +2 -2
  40. package/docs/production-hardening.md +5 -0
  41. package/package.json +5 -2
  42. package/server.json +2 -2
@@ -0,0 +1,263 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { globToRegExp } from '../ark-shared.mjs';
5
+ import { lineOf } from './ast-scan.mjs';
6
+ import { normalize } from './scan-files.mjs';
7
+
8
+ const IN_MEMORY_STORES = new Set([
9
+ 'InMemoryAuditStore',
10
+ 'InMemoryOutboxStore',
11
+ 'InMemoryReadModelStore',
12
+ 'InMemoryWorkflowStore',
13
+ ]);
14
+
15
+ const IN_MEMORY_DEFAULT_FACTORIES = new Map([
16
+ ['createArkKernel', ['outbox', 'auditTrail', 'projections']],
17
+ ['createAuditTrail', ['store']],
18
+ ['createProjectionRegistry', ['store']],
19
+ ['createWorkflowEngine', ['store']],
20
+ ]);
21
+
22
+ function matchesAny(relFile, patterns) {
23
+ return patterns.some((pattern) => {
24
+ try {
25
+ return globToRegExp(pattern).test(relFile);
26
+ } catch {
27
+ return false;
28
+ }
29
+ });
30
+ }
31
+
32
+ function packageName(root) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ function propertyName(ts, node) {
41
+ if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
42
+ return undefined;
43
+ }
44
+
45
+ function objectHasProperty(ts, object, name) {
46
+ return object.properties.some((property) => {
47
+ if (ts.isShorthandPropertyAssignment(property)) return property.name.text === name;
48
+ return property.name ? propertyName(ts, property.name) === name : false;
49
+ });
50
+ }
51
+
52
+ function tsSuppressionPositions(sourceFile, source) {
53
+ const positions = new Set();
54
+ for (const directive of sourceFile.commentDirectives ?? []) {
55
+ const start = directive.range?.pos;
56
+ const end = directive.range?.end;
57
+ if (Number.isInteger(start) && Number.isInteger(end)) {
58
+ const text = source.slice(start, end);
59
+ if (/\@ts-ignore\b/.test(text)) positions.add(start);
60
+ }
61
+ }
62
+
63
+ const noCheck = sourceFile.pragmas?.get?.('ts-nocheck');
64
+ const entries = Array.isArray(noCheck) ? noCheck : noCheck ? [noCheck] : [];
65
+ for (const entry of entries) {
66
+ const start = entry.range?.pos;
67
+ if (Number.isInteger(start)) positions.add(start);
68
+ }
69
+ return [...positions];
70
+ }
71
+
72
+ export function collectSafetyDiagnostics(ts, root, config, files) {
73
+ const safety = config.safety ?? {};
74
+ const dynamicAllowlist = Array.isArray(config.dynamicImportAllowlist)
75
+ ? config.dynamicImportAllowlist
76
+ : [];
77
+ const maxTsSuppressions = Number.isInteger(safety.maxTsSuppressions)
78
+ ? safety.maxTsSuppressions
79
+ : 0;
80
+ const maxAnyCasts = Number.isInteger(safety.maxAnyCasts) ? safety.maxAnyCasts : 0;
81
+ const allowInMemory = safety.allowInMemory === true;
82
+ const isProvider = packageName(root) === 'arkgate';
83
+ const report = {
84
+ tsSuppressions: [],
85
+ anyCasts: [],
86
+ nonLiteralDynamicImports: [],
87
+ inMemoryProductionStores: [],
88
+ disabledPeerIsolationRules: [],
89
+ thresholds: { maxTsSuppressions, maxAnyCasts },
90
+ };
91
+
92
+ if (safety.allowDisabledPeerIsolation !== true) {
93
+ report.disabledPeerIsolationRules = (config.rules ?? [])
94
+ .filter(
95
+ (rule) =>
96
+ rule?.peerIsolation === false ||
97
+ (rule?.allowed === false &&
98
+ rule?.from &&
99
+ rule.from === rule.to &&
100
+ rule.peerIsolation !== true)
101
+ )
102
+ .map((rule) => ({ from: rule.from, to: rule.to }));
103
+ }
104
+
105
+ for (const file of files) {
106
+ const source = fs.readFileSync(file, 'utf8');
107
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
108
+ const relFile = normalize(path.relative(root, file));
109
+
110
+ for (const position of tsSuppressionPositions(sourceFile, source)) {
111
+ report.tsSuppressions.push({
112
+ file: relFile,
113
+ line: lineOf(sourceFile, position),
114
+ });
115
+ }
116
+
117
+ const importedFactories = new Map();
118
+ const arkNamespaces = new Set();
119
+ if (!allowInMemory && !isProvider) {
120
+ for (const statement of sourceFile.statements) {
121
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier)) {
122
+ continue;
123
+ }
124
+ if (!/^arkgate(?:\/runtime)?$/.test(statement.moduleSpecifier.text)) continue;
125
+ const bindings = statement.importClause?.namedBindings;
126
+ if (bindings && ts.isNamespaceImport(bindings)) arkNamespaces.add(bindings.name.text);
127
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
128
+ for (const element of bindings.elements) {
129
+ const imported = element.propertyName?.text ?? element.name.text;
130
+ const requirements = IN_MEMORY_DEFAULT_FACTORIES.get(imported);
131
+ if (requirements) {
132
+ importedFactories.set(element.name.text, { imported, requirements });
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ const visit = (node) => {
139
+ if (
140
+ (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) &&
141
+ node.type?.kind === ts.SyntaxKind.AnyKeyword
142
+ ) {
143
+ report.anyCasts.push({
144
+ file: relFile,
145
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
146
+ });
147
+ }
148
+
149
+ if (ts.isCallExpression(node) && node.expression?.kind === ts.SyntaxKind.ImportKeyword) {
150
+ const argument = node.arguments[0];
151
+ if (!argument || !ts.isStringLiteralLike(argument)) {
152
+ if (!matchesAny(relFile, dynamicAllowlist)) {
153
+ report.nonLiteralDynamicImports.push({
154
+ file: relFile,
155
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ if (!allowInMemory && !isProvider && ts.isImportDeclaration(node)) {
162
+ const specifier = node.moduleSpecifier;
163
+ const fromArk = ts.isStringLiteralLike(specifier) && /^arkgate(?:\/runtime)?$/.test(specifier.text);
164
+ if (fromArk) {
165
+ const elements = node.importClause?.namedBindings &&
166
+ ts.isNamedImports(node.importClause.namedBindings)
167
+ ? node.importClause.namedBindings.elements
168
+ : [];
169
+ for (const element of elements) {
170
+ const imported = element.propertyName?.text ?? element.name.text;
171
+ if (IN_MEMORY_STORES.has(imported)) {
172
+ report.inMemoryProductionStores.push({
173
+ file: relFile,
174
+ line: lineOf(sourceFile, element.getStart(sourceFile)),
175
+ store: imported,
176
+ });
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ if (!allowInMemory && !isProvider && ts.isCallExpression(node)) {
183
+ let factory;
184
+ if (ts.isIdentifier(node.expression)) {
185
+ factory = importedFactories.get(node.expression.text);
186
+ } else if (
187
+ ts.isPropertyAccessExpression(node.expression) &&
188
+ ts.isIdentifier(node.expression.expression) &&
189
+ arkNamespaces.has(node.expression.expression.text)
190
+ ) {
191
+ const imported = node.expression.name.text;
192
+ const requirements = IN_MEMORY_DEFAULT_FACTORIES.get(imported);
193
+ if (requirements) factory = { imported, requirements };
194
+ }
195
+
196
+ if (factory) {
197
+ const options = node.arguments[0];
198
+ const definitelyDefaults =
199
+ !options ||
200
+ (ts.isIdentifier(options) && options.text === 'undefined') ||
201
+ (ts.isObjectLiteralExpression(options) &&
202
+ factory.requirements.some((name) => !objectHasProperty(ts, options, name)));
203
+ if (definitelyDefaults) {
204
+ report.inMemoryProductionStores.push({
205
+ file: relFile,
206
+ line: lineOf(sourceFile, node.getStart(sourceFile)),
207
+ store: `${factory.imported} defaults`,
208
+ });
209
+ }
210
+ }
211
+ }
212
+
213
+ ts.forEachChild(node, visit);
214
+ };
215
+ visit(sourceFile);
216
+ }
217
+
218
+ const warnings = [];
219
+ if (report.nonLiteralDynamicImports.length > 0) {
220
+ const first = report.nonLiteralDynamicImports[0];
221
+ warnings.push({
222
+ ruleId: 'DYNAMIC_IMPORT_NOT_ALLOWLISTED',
223
+ file: first.file,
224
+ line: first.line,
225
+ message: `${report.nonLiteralDynamicImports.length} non-literal dynamic import(s) cannot be resolved statically. Add only reviewed files to dynamicImportAllowlist.`,
226
+ });
227
+ }
228
+ if (report.tsSuppressions.length > maxTsSuppressions) {
229
+ const first = report.tsSuppressions[0];
230
+ warnings.push({
231
+ ruleId: 'TS_SUPPRESSION_THRESHOLD_EXCEEDED',
232
+ file: first.file,
233
+ line: first.line,
234
+ message: `${report.tsSuppressions.length} @ts-ignore/@ts-nocheck directive(s) exceed safety.maxTsSuppressions (${maxTsSuppressions}).`,
235
+ });
236
+ }
237
+ if (report.anyCasts.length > maxAnyCasts) {
238
+ const first = report.anyCasts[0];
239
+ warnings.push({
240
+ ruleId: 'ANY_CAST_THRESHOLD_EXCEEDED',
241
+ file: first.file,
242
+ line: first.line,
243
+ message: `${report.anyCasts.length} explicit any cast(s) exceed safety.maxAnyCasts (${maxAnyCasts}).`,
244
+ });
245
+ }
246
+ if (report.inMemoryProductionStores.length > 0) {
247
+ const first = report.inMemoryProductionStores[0];
248
+ warnings.push({
249
+ ruleId: 'IN_MEMORY_STORE_IN_PRODUCTION_SOURCE',
250
+ file: first.file,
251
+ line: first.line,
252
+ message: `${report.inMemoryProductionStores.length} ArkGate InMemory store risk(s) appear in governed production source. Provide durable stores or set safety.allowInMemory only for an explicitly ephemeral service.`,
253
+ });
254
+ }
255
+ if (report.disabledPeerIsolationRules.length > 0) {
256
+ warnings.push({
257
+ ruleId: 'PEER_ISOLATION_DISABLED',
258
+ message: `${report.disabledPeerIsolationRules.length} rule(s) disable or omit required peerIsolation. Restore peerIsolation: true or set safety.allowDisabledPeerIsolation only with a documented production exception.`,
259
+ });
260
+ }
261
+
262
+ return { report, warnings };
263
+ }
@@ -32,24 +32,62 @@ export function isSkippedSourceDir(name) {
32
32
  );
33
33
  }
34
34
 
35
- export function walk(dir, files = []) {
36
- const stat = fs.statSync(dir, { throwIfNoEntry: false });
35
+ function isInsideRoot(root, target) {
36
+ const rel = path.relative(root, target);
37
+ return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
38
+ }
39
+
40
+ /**
41
+ * Walk source files while treating symlinks explicitly.
42
+ *
43
+ * When `root` is provided, every resolved file/directory must stay inside it.
44
+ * Internal symlink directories are followed once (TypeScript follows them too),
45
+ * while escaping links fail closed instead of reading arbitrary filesystem paths.
46
+ */
47
+ export function walk(dir, files = [], options = {}) {
48
+ const state = options.state ?? {
49
+ root: options.root ? fs.realpathSync(options.root) : undefined,
50
+ visitedDirectories: new Set(),
51
+ visitedFiles: new Set(),
52
+ };
53
+ const lstat = fs.lstatSync(dir, { throwIfNoEntry: false });
54
+ if (!lstat) return files;
55
+ const resolved = fs.realpathSync(dir);
56
+ if (state.root && !isInsideRoot(state.root, resolved)) {
57
+ throw new Error(
58
+ `Refusing to scan symlink outside project root: ${dir} -> ${resolved}`
59
+ );
60
+ }
61
+ const stat = lstat.isSymbolicLink()
62
+ ? fs.statSync(dir, { throwIfNoEntry: false })
63
+ : lstat;
37
64
  if (!stat) return files;
38
65
  // An `include` entry may be a single file (e.g. a root-level "middleware.ts"),
39
66
  // not just a directory — govern it directly instead of trying to scandir it
40
67
  // (which threw ENOTDIR). The extension filter still applies.
41
68
  if (stat.isFile()) {
42
- if (isGovernableSourceFile(path.basename(dir))) files.push(dir);
69
+ if (
70
+ isGovernableSourceFile(path.basename(dir)) &&
71
+ !state.visitedFiles.has(resolved)
72
+ ) {
73
+ state.visitedFiles.add(resolved);
74
+ files.push(dir);
75
+ }
43
76
  return files;
44
77
  }
45
78
  if (!stat.isDirectory()) return files;
79
+ if (state.visitedDirectories.has(resolved)) return files;
80
+ state.visitedDirectories.add(resolved);
46
81
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
47
82
  const full = path.join(dir, entry.name);
48
83
  if (entry.isDirectory()) {
49
84
  if (isSkippedSourceDir(entry.name)) continue;
50
- walk(full, files);
85
+ walk(full, files, { state });
86
+ } else if (entry.isSymbolicLink()) {
87
+ if (isSkippedSourceDir(entry.name)) continue;
88
+ walk(full, files, { state });
51
89
  } else if (isGovernableSourceFile(entry.name)) {
52
- files.push(full);
90
+ walk(full, files, { state });
53
91
  }
54
92
  }
55
93
  return files;
@@ -57,7 +95,14 @@ export function walk(dir, files = []) {
57
95
 
58
96
  /** Walk include roots then drop codegen / config.exclude (universal scan filter). */
59
97
  export function collectGovernedFiles(root, config) {
60
- const raw = (config.include ?? []).flatMap((entry) => walk(path.join(root, entry)));
98
+ const state = {
99
+ root: fs.realpathSync(root),
100
+ visitedDirectories: new Set(),
101
+ visitedFiles: new Set(),
102
+ };
103
+ const raw = (config.include ?? []).flatMap((entry) =>
104
+ walk(path.join(root, entry), [], { state })
105
+ );
61
106
  return raw.filter((abs) => {
62
107
  const rel = normalize(path.relative(root, abs));
63
108
  return !isScanExcludedRelative(rel, config);
@@ -11,8 +11,8 @@ const color = {
11
11
  };
12
12
 
13
13
  /** Canonical: src/domain/baselineKey.ts → bin/lib/baseline-key.mjs (R4). */
14
- import { baselineKey } from './baseline-key.mjs';
15
- export { baselineKey };
14
+ import { baselineKey, baselineOccurrenceKeys } from './baseline-key.mjs';
15
+ export { baselineKey, baselineOccurrenceKeys };
16
16
 
17
17
  export function readBaseline(root, baselinePath) {
18
18
  const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
@@ -23,7 +23,7 @@ export function readBaseline(root, baselinePath) {
23
23
 
24
24
  export function writeBaseline(root, baselinePath, violations) {
25
25
  const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
26
- const keys = [...new Set(violations.map(baselineKey))].sort();
26
+ const keys = baselineOccurrenceKeys(violations).sort();
27
27
  fs.writeFileSync(
28
28
  fullPath,
29
29
  `${JSON.stringify({ version: 1, note: 'Frozen ark-check violations. Only NEW violations fail --baseline runs. Regenerate with: ark-check --update-baseline', violations: keys }, null, 2)}\n`
package/dist/index.cjs CHANGED
@@ -80,7 +80,7 @@ __export(index_exports, {
80
80
  module.exports = __toCommonJS(index_exports);
81
81
 
82
82
  // src/version.ts
83
- var version = "2.10.0";
83
+ var version = "2.11.0";
84
84
 
85
85
  // src/kernel/intent/IntentRegistry.ts
86
86
  var IntentRegistry = class {
@@ -2262,6 +2262,75 @@ function extractModuleSpecifiers(source) {
2262
2262
  }
2263
2263
  return matches.sort((a, b) => a.index - b.index);
2264
2264
  }
2265
+ function extractModuleSpecifiersAst(ts, source) {
2266
+ const sourceFile = ts.createSourceFile("generated.ts", source, ts.ScriptTarget.Latest, true);
2267
+ const matches = [];
2268
+ const push = (node, value, kind, typeOnly = false) => {
2269
+ matches.push({
2270
+ value,
2271
+ index: node.getStart(sourceFile),
2272
+ kind,
2273
+ typeOnly
2274
+ });
2275
+ };
2276
+ const visit = (node) => {
2277
+ if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
2278
+ const clause = node.importClause;
2279
+ const namedBindings = clause?.namedBindings;
2280
+ const specifiersOnly = clause && !clause.name && namedBindings && ts.isNamedImports(namedBindings) && namedBindings.elements.length > 0 && namedBindings.elements.every((element) => element.isTypeOnly === true);
2281
+ push(
2282
+ node.moduleSpecifier,
2283
+ node.moduleSpecifier.text,
2284
+ "import",
2285
+ Boolean(clause?.isTypeOnly || specifiersOnly)
2286
+ );
2287
+ } else if (ts.isExportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
2288
+ const clause = node.exportClause;
2289
+ const specifiersOnly = clause && ts.isNamedExports(clause) && clause.elements.length > 0 && clause.elements.every((element) => element.isTypeOnly === true);
2290
+ push(
2291
+ node.moduleSpecifier,
2292
+ node.moduleSpecifier.text,
2293
+ "export",
2294
+ Boolean(node.isTypeOnly || specifiersOnly)
2295
+ );
2296
+ } else if (ts.isCallExpression(node) && node.arguments.length === 1) {
2297
+ const argument = node.arguments[0];
2298
+ const value = tsStringLiteralText(ts, argument);
2299
+ if (value !== void 0 && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
2300
+ push(argument, value, "dynamic-import");
2301
+ } else if (value !== void 0 && ts.isIdentifier(node.expression) && node.expression.text === "require") {
2302
+ push(argument, value, "require");
2303
+ }
2304
+ }
2305
+ ts.forEachChild(node, visit);
2306
+ };
2307
+ visit(sourceFile);
2308
+ return matches.sort((a, b) => a.index - b.index);
2309
+ }
2310
+ function nonLiteralDynamicImportLines(ts, source) {
2311
+ const sourceFile = ts.createSourceFile("generated.ts", source, ts.ScriptTarget.Latest, true);
2312
+ const lines = [];
2313
+ const visit = (node) => {
2314
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && (!node.arguments[0] || !ts.isStringLiteralLike(node.arguments[0]))) {
2315
+ lines.push(sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1);
2316
+ }
2317
+ ts.forEachChild(node, visit);
2318
+ };
2319
+ visit(sourceFile);
2320
+ return lines;
2321
+ }
2322
+ function extractQuotedStringsAst(ts, source) {
2323
+ const sourceFile = ts.createSourceFile("generated.ts", source, ts.ScriptTarget.Latest, true);
2324
+ const matches = [];
2325
+ const visit = (node) => {
2326
+ if (ts.isStringLiteralLike(node)) {
2327
+ matches.push({ value: node.text, index: node.getStart(sourceFile) });
2328
+ }
2329
+ ts.forEachChild(node, visit);
2330
+ };
2331
+ visit(sourceFile);
2332
+ return matches;
2333
+ }
2265
2334
  function looksLikeIntentName(s) {
2266
2335
  return /^(Domain|Application|Adapter|Workflow|Job|Presentation|Reporting|Metadata|Security|Audit|Observability|Kernel)\.[A-Za-z0-9_.]+$/.test(s);
2267
2336
  }
@@ -2456,6 +2525,19 @@ function createAICodeGate(options = {}) {
2456
2525
  const gateContext = context;
2457
2526
  const filePath = gateContext?.filePath;
2458
2527
  const contextLayer = gateContext?.layer;
2528
+ const moduleSpecifiers = options.typescript ? extractModuleSpecifiersAst(options.typescript, source) : extractModuleSpecifiers(source);
2529
+ const quotedStrings = options.typescript ? extractQuotedStringsAst(options.typescript, source) : extractQuotedStrings(source);
2530
+ if (options.typescript && !options.allowNonLiteralDynamicImport?.(filePath)) {
2531
+ for (const line of nonLiteralDynamicImportLines(options.typescript, source)) {
2532
+ violations.push(
2533
+ violation(
2534
+ "DYNAMIC_IMPORT_NOT_ALLOWLISTED",
2535
+ "Non-literal dynamic import cannot be resolved statically; add the reviewed file to dynamicImportAllowlist.",
2536
+ { line, filePath }
2537
+ )
2538
+ );
2539
+ }
2540
+ }
2459
2541
  const exemptFromInfraHeuristics = contextLayer !== void 0 && (explicitInfraLayers.has(contextLayer) || layerHasInfrastructureRole(contextLayer));
2460
2542
  const infraLayerEscapeHatch = contextLayer !== void 0 ? ` If "${contextLayer}" is an infrastructure layer, mark it in ark.config.json with "mayImportInfrastructure": true (or name it with an infra token like Adapters/Persistence/Repository).` : "";
2461
2543
  for (const pat of userForbidden) {
@@ -2481,7 +2563,7 @@ function createAICodeGate(options = {}) {
2481
2563
  );
2482
2564
  }
2483
2565
  }
2484
- for (const specifier of extractModuleSpecifiers(source)) {
2566
+ for (const specifier of moduleSpecifiers) {
2485
2567
  const targetHit = options.resolveImportTarget?.(specifier.value, filePath) ?? (options.resolveImportLayer ? { layer: options.resolveImportLayer(specifier.value, filePath) } : void 0);
2486
2568
  const sourceHit = typeof filePath === "string" ? options.resolveImportTarget?.(filePath) ?? (options.resolveImportLayer ? { layer: contextLayer, relPath: void 0 } : void 0) : void 0;
2487
2569
  const targetLayer = targetHit?.layer;
@@ -2572,7 +2654,7 @@ function createAICodeGate(options = {}) {
2572
2654
  }
2573
2655
  }
2574
2656
  if (enforceAllowlist && intentNames.size > 0) {
2575
- for (const literal of extractQuotedStrings(source)) {
2657
+ for (const literal of quotedStrings) {
2576
2658
  if (looksLikeIntentName(literal.value) && !intentNames.has(literal.value)) {
2577
2659
  violations.push(
2578
2660
  violation(
@@ -2590,7 +2672,7 @@ function createAICodeGate(options = {}) {
2590
2672
  }
2591
2673
  }
2592
2674
  if (options.architectureProfile && contextLayer) {
2593
- for (const literal of extractQuotedStrings(source)) {
2675
+ for (const literal of quotedStrings) {
2594
2676
  if (!looksLikeIntentName(literal.value)) continue;
2595
2677
  const targetLayer = options.architectureProfile.resolveLayer(literal.value);
2596
2678
  if (!targetLayer) continue;
@@ -2866,15 +2948,18 @@ function sleep(ms) {
2866
2948
  });
2867
2949
  }
2868
2950
  async function withTimeout(operation, timeoutMs, stepName) {
2869
- if (timeoutMs === void 0) return operation;
2951
+ const controller = new AbortController();
2952
+ if (timeoutMs === void 0) return operation(controller.signal);
2870
2953
  let timeout;
2871
2954
  const timeoutPromise = new Promise((_, reject) => {
2872
2955
  timeout = setTimeout(() => {
2873
- reject(new Error(`Workflow step "${stepName}" timed out after ${timeoutMs}ms.`));
2956
+ const error = new Error(`Workflow step "${stepName}" timed out after ${timeoutMs}ms.`);
2957
+ controller.abort(error);
2958
+ reject(error);
2874
2959
  }, timeoutMs);
2875
2960
  });
2876
2961
  try {
2877
- return await Promise.race([operation, timeoutPromise]);
2962
+ return await Promise.race([operation(controller.signal), timeoutPromise]);
2878
2963
  } finally {
2879
2964
  if (timeout) clearTimeout(timeout);
2880
2965
  }
@@ -2909,6 +2994,15 @@ var WorkflowEngineImpl = class {
2909
2994
  if (this.definitions.has(definition.name)) {
2910
2995
  throw new Error(`Workflow "${definition.name}" is already registered.`);
2911
2996
  }
2997
+ const names = /* @__PURE__ */ new Set();
2998
+ for (const step of definition.steps) {
2999
+ if (names.has(step.name)) {
3000
+ throw new Error(
3001
+ `Workflow "${definition.name}" has duplicate step name "${step.name}".`
3002
+ );
3003
+ }
3004
+ names.add(step.name);
3005
+ }
2912
3006
  this.definitions.set(definition.name, definition);
2913
3007
  if (definition.startOn) {
2914
3008
  const trigger = definition.startOn;
@@ -2949,6 +3043,7 @@ var WorkflowEngineImpl = class {
2949
3043
  } catch (err) {
2950
3044
  await this.compensate(snapshot, definition.steps, err);
2951
3045
  snapshot.status = "failed";
3046
+ snapshot.currentStep = void 0;
2952
3047
  snapshot.error = errorMessage(err);
2953
3048
  snapshot.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2954
3049
  await this.store.save(snapshot);
@@ -2977,7 +3072,7 @@ var WorkflowEngineImpl = class {
2977
3072
  await this.store.save(snapshot);
2978
3073
  try {
2979
3074
  const result = await withTimeout(
2980
- Promise.resolve(step.execute(snapshot.context, this.bus)),
3075
+ (signal) => Promise.resolve(step.execute(snapshot.context, this.bus, signal)),
2981
3076
  step.timeoutMs,
2982
3077
  step.name
2983
3078
  );