arkgate 2.7.0 → 2.8.1

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 (40) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +8 -0
  3. package/bin/lib/agent-gates.mjs +48 -228
  4. package/bin/lib/architecture-scan.mjs +20 -0
  5. package/bin/lib/ast-scan.mjs +232 -4
  6. package/bin/lib/codex-home.mjs +320 -0
  7. package/bin/lib/doctor-plan.mjs +2 -0
  8. package/bin/lib/remediation.mjs +44 -12
  9. package/bin/lib/ts-resolve.mjs +5 -4
  10. package/dist/index.cjs +417 -338
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +29 -9
  13. package/dist/index.d.ts +29 -9
  14. package/dist/index.js +417 -338
  15. package/dist/index.js.map +1 -1
  16. package/dist/nestjs/index.cjs +452 -373
  17. package/dist/nestjs/index.cjs.map +1 -1
  18. package/dist/nestjs/index.d.cts +1 -1
  19. package/dist/nestjs/index.d.ts +1 -1
  20. package/dist/nestjs/index.js +452 -373
  21. package/dist/nestjs/index.js.map +1 -1
  22. package/dist/runtime/index.cjs +417 -338
  23. package/dist/runtime/index.cjs.map +1 -1
  24. package/dist/runtime/index.d.cts +1 -1
  25. package/dist/runtime/index.d.ts +1 -1
  26. package/dist/runtime/index.js +417 -338
  27. package/dist/runtime/index.js.map +1 -1
  28. package/dist/{types-CP3KkwZt.d.cts → types-CSJhEOk2.d.cts} +34 -0
  29. package/dist/{types-CP3KkwZt.d.ts → types-CSJhEOk2.d.ts} +34 -0
  30. package/docs/agent-guide.md +1 -1
  31. package/docs/ai-gates.md +41 -7
  32. package/docs/brownfield-adoption.md +7 -0
  33. package/docs/demos/03-copilot-autopilot.md +3 -2
  34. package/docs/enthusiast/reference-commands.md +2 -2
  35. package/docs/package-surface.md +1 -1
  36. package/docs/production-hardening.md +11 -4
  37. package/package.json +2 -1
  38. package/server.json +2 -2
  39. package/templates/skills/ark-explain.md +3 -2
  40. package/templates/skills/ark-loop.md +2 -1
package/CHANGELOG.md CHANGED
@@ -2,7 +2,51 @@
2
2
 
3
3
  All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are documented here.
4
4
 
5
- ## Unreleased
5
+ ## 2.8.1 — 2026-07-09
6
+
7
+ Runtime honesty release (roadmap **R8–R9**): EventBus publish pipeline decomposition and
8
+ explicit InMemory durability stance. **No intentional CLI flag or JSON shape breaks.**
9
+
10
+ ### Changed
11
+
12
+ - **R8 — EventBus decomposition:** publish pipeline split into cohesive modules under
13
+ `src/kernel/event-bus/` (`payloadPatch`, `publishGuards`, `publishInterceptors`,
14
+ `observedLayerFlow`, `publishPolicy`, `publishRecording`). `EventBus.ts` is
15
+ orchestration + public surface only. **`createEventBus` API and enforcement order
16
+ unchanged.** Snapshot of subscribers still taken before policy hooks.
17
+ - **R9 — Runtime durability stance:** built-in stores are documented as **reference
18
+ InMemory-only** (not production durability) in README, `docs/production-hardening.md`,
19
+ `docs/package-surface.md`, and JSDoc on `OutboxStore` / `AuditStore` /
20
+ `ReadModelStore` / `WorkflowStore` (+ InMemory implementations). No durable adapter
21
+ shipped — inject your own for production.
22
+
23
+ ## 2.8.0 — 2026-07-09
24
+
25
+ Co-pilot quality release (roadmap **R5–R7**): labeled eval corpus, fourth mechanical-safe kind,
26
+ and Codex multi-project MCP without silent primary overwrite. **No intentional CLI flag or JSON
27
+ shape breaks** for the gate/co-pilot path.
28
+
29
+ ### Added
30
+
31
+ - **R5 — labeled eval corpus:** 16 cases under `eval/cases/` (themes + labels).
32
+ `npm run eval:corpus` / `evalCorpus.test.ts` gate without a live agent.
33
+ - **R6 — `import-type-of-type-exports`:** named type-only exports from mixed modules →
34
+ `import type` / `export type`. Dual-space names and targets with top-level side effects stay
35
+ **judgment**. Scan flags `namedBindingsTypeOnly` (+ `hasTopLevelSideEffects`).
36
+ - **R7 — Codex multi-project MCP DX:** no silent primary steal; scoped
37
+ `[mcp_servers.ark_<slug>_<hash>]`; doctor gap `codex-home-multi-project`. Codex home logic in
38
+ `bin/lib/codex-home.mjs`.
39
+
40
+ ### Changed
41
+
42
+ - Scan cache schema **v6** (typeOnlyExportNames, namedBindings, hasTopLevelSideEffects;
43
+ invalidates v5 after non-export side-effect honesty fix).
44
+ - Classifier: single early judgment for `require` / `dynamic-import` on layer edges.
45
+ - R6 honesty: impure value-export initializers (`export const db = connect()`) count as
46
+ top-level side effects — named type imports of those modules stay **judgment**.
47
+ - R6 honesty: non-exported impure top-level initializers (`const boot = setup()`) and
48
+ non-exported class static field calls also count as side effects (same skip-on-import-type risk).
49
+ - Codex home: single `upsertCodexMcpTable` path for primary and secondary MCP tables.
6
50
 
7
51
  ## 2.7.0 — 2026-07-09
8
52
 
package/README.md CHANGED
@@ -231,6 +231,14 @@ in this major; prefer `arkgate/runtime` for new code.
231
231
 
232
232
  NestJS: `arkgate/nestjs` (optional peer `@nestjs/common`).
233
233
 
234
+ ### Durability stance (built-in stores)
235
+
236
+ The kernel’s default stores (`InMemoryOutboxStore`, `InMemoryAuditStore`,
237
+ `InMemoryReadModelStore`, `InMemoryWorkflowStore`) are **reference in-memory only**:
238
+ fine for tests, demos, and single-process local work — they **do not** survive restarts
239
+ and are **not** production durability. Implement the store interfaces (or inject your own)
240
+ for real systems. Details: [docs/production-hardening.md](docs/production-hardening.md).
241
+
234
242
  ---
235
243
 
236
244
  ## Documentation
@@ -3,7 +3,6 @@
3
3
  */
4
4
  import { createRequire } from 'node:module';
5
5
  import { spawnSync } from 'node:child_process';
6
- import crypto from 'node:crypto';
7
6
  import fs from 'node:fs';
8
7
  import os from 'node:os';
9
8
  import path from 'node:path';
@@ -23,6 +22,40 @@ import {
23
22
  createElevenLayerConfig,
24
23
  applyFrameworkLayoutOverlays
25
24
  } from '../ark-shared.mjs';
25
+ import {
26
+ assessCodexHomeMcp,
27
+ codexArkBlockHasPreferredBin,
28
+ codexArkBlockNeedsRewrite,
29
+ codexConfigPath,
30
+ codexPrimaryTable,
31
+ codexProjectSlug,
32
+ codexPromptsDir,
33
+ codexScopedTableForRoot,
34
+ extractCodexArkRootFromToml,
35
+ extractCodexRootFromBlock,
36
+ isTempOrUpgradeRoot,
37
+ listCodexArkServerTables,
38
+ upsertCodexMcpTable,
39
+ wireCodexMcp,
40
+ } from './codex-home.mjs';
41
+
42
+ // Re-export Codex home API for existing consumers (ark-check, tests).
43
+ export {
44
+ assessCodexHomeMcp,
45
+ codexArkBlockHasPreferredBin,
46
+ codexArkBlockNeedsRewrite,
47
+ codexConfigPath,
48
+ codexPrimaryTable,
49
+ codexProjectSlug,
50
+ codexPromptsDir,
51
+ codexScopedTableForRoot,
52
+ extractCodexArkRootFromToml,
53
+ extractCodexRootFromBlock,
54
+ isTempOrUpgradeRoot,
55
+ listCodexArkServerTables,
56
+ upsertCodexMcpTable,
57
+ wireCodexMcp,
58
+ };
26
59
 
27
60
  /** Package root (parent of bin/). All modules live under bin/lib/. */
28
61
  const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -762,136 +795,6 @@ export function skillTemplateNames() {
762
795
  // skills this version ships, surface it here so agents and CI actually notice.
763
796
  // Advisory only — never affects the exit code. Copilot has no reliable directory
764
797
  // signal, so it is not auto-detected (explicit --tools only), matching resolveTools.
765
- // Where Codex loads slash-command prompts from. Codex reads $CODEX_HOME/prompts
766
- // (defaulting to ~/.codex/prompts), NOT the repo — so home copies of the /ark-*
767
- // skills drift out of date when a repo refresh only touches in-repo tool dirs.
768
- export function codexPromptsDir() {
769
- const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
770
- return path.join(base, 'prompts');
771
- }
772
-
773
- // Where Codex reads its MCP server registrations. Unlike Claude (.claude/settings.json)
774
- // and Cursor (.cursor/mcp.json), Codex loads MCP servers only from $CODEX_HOME/config.toml
775
- // (~/.codex/config.toml) — never from .mcp.json — so wiring Codex means editing the user's
776
- // home config, not a repo file.
777
- export function codexConfigPath() {
778
- const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
779
- return path.join(base, 'config.toml');
780
- }
781
-
782
- // Merge the [mcp_servers.ark] table into Codex's config.toml so `ark://manifest` and the
783
- // AI write gate are live from the first edit — the piece that was previously only shipped as
784
- // a copy-me sample in docs/ark-codex-config.toml. Idempotent: an existing ark table is left
785
- // untouched unless `force` replaces it; other content in the file is preserved. Returns a
786
- // status for the install summary. The table match runs from the [mcp_servers.ark] header to
787
- // the line before the next top-level table header (a line starting with `[`) or EOF.
788
- //
789
- // Unlike .mcp.json / .cursor/mcp.json (loaded relative to the project), config.toml is a
790
- // GLOBAL file — Codex launches it without the project as cwd — so `--root .` would resolve
791
- // against the wrong directory. The paths must be absolute, and TOML string values need the
792
- // backslashes/quotes escaped (matters on Windows and for repo paths containing quotes).
793
- export function wireCodexMcp(root, force) {
794
- const file = codexConfigPath();
795
- const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
796
- const absRoot = path.resolve(root);
797
- const absConfig = path.join(absRoot, 'ark.config.json');
798
- // Preferred product bin; absolute --root so Codex (cwd ≠ project) resolves correctly.
799
- const preferredBin = 'arkgate-mcp';
800
- const { command, args } = execCommandParts(root, preferredBin, [
801
- '--root',
802
- esc(absRoot),
803
- '--config',
804
- esc(absConfig),
805
- ]);
806
- const argsToml = args.map((value) => `"${value}"`).join(', ');
807
- const makeBlock = (table) =>
808
- `[mcp_servers.${table}]
809
- command = "${command}"
810
- args = [${argsToml}]`;
811
- let existing = '';
812
- try {
813
- if (fs.existsSync(file)) existing = fs.readFileSync(file, 'utf8');
814
- } catch (error) {
815
- return { status: 'failed', file, message: error.message };
816
- }
817
- const tableRe = /(^|\n)\[mcp_servers\.ark\][^\n]*\n(?:(?!\[)[^\n]*\n?)*/;
818
- const hasTable = tableRe.test(existing);
819
- const existingRoot = hasTable ? extractCodexArkRootFromToml(existing) : null;
820
- let differentProject = false;
821
- try {
822
- differentProject = Boolean(
823
- existingRoot && path.resolve(existingRoot) !== absRoot
824
- );
825
- } catch {
826
- differentProject = Boolean(existingRoot);
827
- }
828
- // Fail-closed: rewrite temp/upgrade roots and dual/wrong bins even without --force.
829
- const mustRewrite = hasTable && codexArkBlockNeedsRewrite(existing, absRoot);
830
-
831
- // Multi-project: another project's [mcp_servers.ark] is present. Without --force,
832
- // add a project-scoped table so we do not steal the primary binding.
833
- if (hasTable && differentProject && !force && !mustRewrite) {
834
- const slug =
835
- path
836
- .basename(absRoot)
837
- .replace(/[^a-zA-Z0-9_-]/g, '_')
838
- .slice(0, 48) || 'project';
839
- const table = `ark_${slug}`;
840
- const multiRe = new RegExp(
841
- `(^|\\n)\\[mcp_servers\\.${table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\][^\\n]*\\n(?:(?!\\[)[^\\n]*\\n?)*`
842
- );
843
- const block = makeBlock(table);
844
- let next;
845
- if (multiRe.test(existing)) {
846
- next = existing.replace(multiRe, (match) => `${match.startsWith('\n') ? '\n' : ''}${block}\n`);
847
- } else {
848
- const sep =
849
- existing.length === 0
850
- ? ''
851
- : existing.endsWith('\n\n')
852
- ? ''
853
- : existing.endsWith('\n')
854
- ? '\n'
855
- : '\n\n';
856
- next = `${existing}${sep}${block}\n`;
857
- }
858
- try {
859
- fs.mkdirSync(path.dirname(file), { recursive: true });
860
- fs.writeFileSync(file, next);
861
- } catch (error) {
862
- return { status: 'failed', file, message: error.message };
863
- }
864
- return { status: 'written-multi', file, table, primaryUnchanged: true };
865
- }
866
-
867
- if (hasTable && !force && !mustRewrite) {
868
- return { status: 'skipped', file };
869
- }
870
- const block = makeBlock('ark');
871
- let next;
872
- if (hasTable) {
873
- next = existing.replace(tableRe, (match) => `${match.startsWith('\n') ? '\n' : ''}${block}\n`);
874
- } else {
875
- const sep = existing.length === 0 ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
876
- next = `${existing}${sep}${block}\n`;
877
- }
878
- try {
879
- fs.mkdirSync(path.dirname(file), { recursive: true });
880
- fs.writeFileSync(file, next);
881
- } catch (error) {
882
- return { status: 'failed', file, message: error.message };
883
- }
884
- return {
885
- status: hasTable ? 'updated' : 'written',
886
- file,
887
- ...(mustRewrite && !force ? { reason: 'temp-or-stale-root' } : {}),
888
- };
889
- }
890
-
891
- // Detects stale/missing /ark-* skills in the Codex home prompts dir. Only nags
892
- // when at least one ark-* prompt already lives there (evidence Codex was set up
893
- // for this user) — never introduces Codex to someone who doesn't use it. Same
894
- // guards as detectSkillGaps (adopted repo, not the Ark source tree).
895
798
  export function detectCodexHomeGap(root) {
896
799
  if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
897
800
  if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
@@ -1027,73 +930,6 @@ const CORE_LAYER_NAMES = new Set([
1027
930
  'PersistenceAdapters',
1028
931
  ]);
1029
932
 
1030
- /** Temp / upgrade sandbox roots must never remain as Codex MCP --root. */
1031
- export function isTempOrUpgradeRoot(p) {
1032
- if (!p || typeof p !== 'string') return false;
1033
- const n = p.replace(/\\/g, '/');
1034
- return (
1035
- /\/var\/folders\//i.test(n) ||
1036
- /\/tmp\//i.test(n) ||
1037
- /\/Temp\//i.test(n) ||
1038
- /ark-upgrade/i.test(n) ||
1039
- /\/T\/(?:ark-|grok-)/i.test(n) ||
1040
- /[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(n)
1041
- );
1042
- }
1043
-
1044
- /** Extract --root value from Codex [mcp_servers.ark] args array text. */
1045
- export function extractCodexArkRootFromToml(tomlText) {
1046
- if (!tomlText || typeof tomlText !== 'string') return null;
1047
- const start = tomlText.search(/(^|\n)\[mcp_servers\.ark\]/);
1048
- if (start < 0) return null;
1049
- const rest = tomlText.slice(start);
1050
- const endMatch = rest.slice(1).search(/\n\[/);
1051
- const block = endMatch >= 0 ? rest.slice(0, endMatch + 1) : rest;
1052
- // args = ["arkgate-mcp", "--root", "/abs/path", ...]
1053
- const rootIdx = block.search(/"--root"\s*,\s*"/);
1054
- if (rootIdx < 0) {
1055
- // alternate: --root as adjacent string after any bin
1056
- const m = block.match(/"--root"\s*,\s*"([^"]+)"/);
1057
- return m ? m[1] : null;
1058
- }
1059
- const m = block.slice(rootIdx).match(/"--root"\s*,\s*"([^"]+)"/);
1060
- return m ? m[1] : null;
1061
- }
1062
-
1063
- export function codexArkBlockHasPreferredBin(tomlText) {
1064
- if (!tomlText) return false;
1065
- const start = tomlText.search(/(^|\n)\[mcp_servers\.ark\]/);
1066
- if (start < 0) return false;
1067
- const rest = tomlText.slice(start);
1068
- const endMatch = rest.slice(1).search(/\n\[/);
1069
- const block = endMatch >= 0 ? rest.slice(0, endMatch + 1) : rest;
1070
- const bins = [...block.matchAll(/"(arkgate-mcp|ark-mcp)"/g)].map((m) => m[1]);
1071
- if (bins.length > 1) return false;
1072
- return bins.length === 1 && bins[0] === PREFERRED_MCP_BIN;
1073
- }
1074
-
1075
- /**
1076
- * True when the primary [mcp_servers.ark] block is broken (temp root / dual bin)
1077
- * and should be rewritten fail-closed. Different permanent project roots are NOT
1078
- * "broken" — multi-project wiring uses a secondary table instead.
1079
- */
1080
- export function codexArkBlockNeedsRewrite(tomlText, absRoot) {
1081
- if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) return true;
1082
- const rootArg = extractCodexArkRootFromToml(tomlText);
1083
- if (!rootArg || isTempOrUpgradeRoot(rootArg)) return true;
1084
- // Permanent different project: multi-project path handles this (do not steal primary).
1085
- try {
1086
- if (path.resolve(rootArg) !== path.resolve(absRoot)) {
1087
- if (!isTempOrUpgradeRoot(rootArg)) return false;
1088
- return true;
1089
- }
1090
- } catch {
1091
- return true;
1092
- }
1093
- if (!codexArkBlockHasPreferredBin(tomlText)) return true;
1094
- return false;
1095
- }
1096
-
1097
933
  /**
1098
934
  * Production deploy path quality (universal — any consumer repo).
1099
935
  * Detects when the production build host runs ESLint / typecheck as part of
@@ -1383,7 +1219,7 @@ export function collectAdoptionGaps(root, config, coverage) {
1383
1219
  }
1384
1220
  }
1385
1221
 
1386
- // --- Codex home MCP (temp path / wrong root / dual bin) ---
1222
+ // --- Codex home MCP (temp path / wrong root / multi-project) ---
1387
1223
  let codexHome = null;
1388
1224
  if (adopted && !isProducer) {
1389
1225
  const codexFile = codexConfigPath();
@@ -1394,39 +1230,23 @@ export function collectAdoptionGaps(root, config, coverage) {
1394
1230
  toml = '';
1395
1231
  }
1396
1232
  if (toml.includes('[mcp_servers.ark]')) {
1397
- const rootArg = extractCodexArkRootFromToml(toml);
1398
- const absRoot = path.resolve(root);
1399
- const temp = isTempOrUpgradeRoot(rootArg);
1400
- let wrongRoot = false;
1401
- try {
1402
- wrongRoot = rootArg ? path.resolve(rootArg) !== absRoot : true;
1403
- } catch {
1404
- wrongRoot = true;
1405
- }
1406
- const preferredBin = codexArkBlockHasPreferredBin(toml);
1407
- const needsRewrite = codexArkBlockNeedsRewrite(toml, absRoot);
1233
+ const assessed = assessCodexHomeMcp(toml, root);
1408
1234
  codexHome = {
1409
1235
  file: codexFile,
1410
- root: rootArg,
1411
- tempPath: temp,
1412
- wrongRoot,
1413
- preferredBin,
1414
- needsRewrite,
1236
+ root: assessed.root,
1237
+ tempPath: assessed.tempPath,
1238
+ wrongRoot: assessed.wrongRoot,
1239
+ preferredBin: assessed.preferredBin,
1240
+ needsRewrite: assessed.needsRewrite,
1241
+ multiProject: assessed.multiProject,
1242
+ scopedTable: assessed.scopedTable,
1415
1243
  };
1416
- if (needsRewrite) {
1244
+ if (assessed.gap) {
1417
1245
  gaps.push({
1418
- id: 'codex-home-mcp',
1419
- severity: temp || wrongRoot ? 'warn' : 'info',
1420
- message: temp
1421
- ? `Codex home MCP --root points at a temp/upgrade path (${rootArg})`
1422
- : wrongRoot
1423
- ? `Codex home MCP --root is not this project (${rootArg || 'missing'} ≠ ${absRoot})`
1424
- : `Codex home MCP should use a single ${PREFERRED_MCP_BIN} bin with absolute project paths`,
1425
- fix: arkCommand(
1426
- root,
1427
- 'ark-check',
1428
- '--install-agent-gates --codex-home --force'
1429
- ),
1246
+ id: assessed.gap.id,
1247
+ severity: assessed.gap.severity,
1248
+ message: assessed.gap.message,
1249
+ fix: arkCommand(root, 'ark-check', assessed.gap.fixArgs),
1430
1250
  });
1431
1251
  }
1432
1252
  }
@@ -15,12 +15,15 @@ import {
15
15
  isPublishCall,
16
16
  lineOf,
17
17
  moduleSpecifierFromCall,
18
+ namedModuleBindings,
18
19
  objectHasProperty,
19
20
  publishHasSource,
20
21
  publishSourceLiteral,
21
22
  sourceFileExportsOnlyTypes,
23
+ sourceFileHasTopLevelSideEffects,
22
24
  stringLiteralText,
23
25
  textOfModuleSpecifier,
26
+ typeOnlyExportNames,
24
27
  } from './ast-scan.mjs';
25
28
  import {
26
29
  intentLayersFromManifest,
@@ -64,11 +67,13 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
64
67
  }
65
68
 
66
69
  const checkModuleEdge = (specifier, node, kind, typeOnly = false) => {
70
+ const namedBindings = namedModuleBindings(ts, node);
67
71
  edges.push({
68
72
  specifier,
69
73
  line: lineOf(sourceFile, node.getStart(sourceFile)),
70
74
  kind,
71
75
  typeOnly,
76
+ ...(namedBindings ? { namedBindings } : {}),
72
77
  });
73
78
  };
74
79
 
@@ -165,6 +170,8 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
165
170
  contentViolations: violations,
166
171
  edges,
167
172
  exportsOnlyTypes: sourceFileExportsOnlyTypes(ts, sourceFile),
173
+ typeOnlyExportNames: typeOnlyExportNames(ts, sourceFile),
174
+ hasTopLevelSideEffects: sourceFileHasTopLevelSideEffects(ts, sourceFile),
168
175
  };
169
176
  }
170
177
 
@@ -238,6 +245,18 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
238
245
  const targetTypeOnlyExports =
239
246
  staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
240
247
  const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
248
+ // R6: every named binding is a type-only export of the target (mixed modules OK).
249
+ // Conservative: no dual-space value names, no top-level side effects on target
250
+ // (import type would skip evaluation), no default/namespace/side-effect/export*.
251
+ const targetTypeNames = new Set(targetCached?.typeOnlyExportNames || []);
252
+ const named = edge.namedBindings;
253
+ const namedBindingsTypeOnly =
254
+ staticEdge &&
255
+ Array.isArray(named) &&
256
+ named.length > 0 &&
257
+ targetTypeNames.size > 0 &&
258
+ !targetCached?.hasTopLevelSideEffects &&
259
+ named.every((n) => targetTypeNames.has(n));
241
260
  violations.push({
242
261
  ruleId: 'LAYER_IMPORT_VIOLATION',
243
262
  file: relFile,
@@ -248,6 +267,7 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
248
267
  ...(edge.typeOnly ? { typeOnly: true } : {}),
249
268
  ...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
250
269
  ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
270
+ ...(namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
251
271
  ...(edge.kind ? { edgeKind: edge.kind } : {}),
252
272
  message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
253
273
  });