arkgate 2.7.0 → 2.8.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.
- package/CHANGELOG.md +27 -1
- package/bin/lib/agent-gates.mjs +48 -228
- package/bin/lib/architecture-scan.mjs +20 -0
- package/bin/lib/ast-scan.mjs +232 -4
- package/bin/lib/codex-home.mjs +320 -0
- package/bin/lib/doctor-plan.mjs +2 -0
- package/bin/lib/remediation.mjs +44 -12
- package/bin/lib/ts-resolve.mjs +5 -4
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +1 -1
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/docs/agent-guide.md +1 -1
- package/docs/ai-gates.md +41 -7
- package/docs/brownfield-adoption.md +7 -0
- package/docs/demos/03-copilot-autopilot.md +3 -2
- package/docs/enthusiast/reference-commands.md +2 -2
- package/package.json +2 -1
- package/server.json +2 -2
- package/templates/skills/ark-explain.md +3 -2
- package/templates/skills/ark-loop.md +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are documented here.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 2.8.0 — 2026-07-09
|
|
6
|
+
|
|
7
|
+
Co-pilot quality release (roadmap **R5–R7**): labeled eval corpus, fourth mechanical-safe kind,
|
|
8
|
+
and Codex multi-project MCP without silent primary overwrite. **No intentional CLI flag or JSON
|
|
9
|
+
shape breaks** for the gate/co-pilot path.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **R5 — labeled eval corpus:** 16 cases under `eval/cases/` (themes + labels).
|
|
14
|
+
`npm run eval:corpus` / `evalCorpus.test.ts` gate without a live agent.
|
|
15
|
+
- **R6 — `import-type-of-type-exports`:** named type-only exports from mixed modules →
|
|
16
|
+
`import type` / `export type`. Dual-space names and targets with top-level side effects stay
|
|
17
|
+
**judgment**. Scan flags `namedBindingsTypeOnly` (+ `hasTopLevelSideEffects`).
|
|
18
|
+
- **R7 — Codex multi-project MCP DX:** no silent primary steal; scoped
|
|
19
|
+
`[mcp_servers.ark_<slug>_<hash>]`; doctor gap `codex-home-multi-project`. Codex home logic in
|
|
20
|
+
`bin/lib/codex-home.mjs`.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Scan cache schema **v6** (typeOnlyExportNames, namedBindings, hasTopLevelSideEffects;
|
|
25
|
+
invalidates v5 after non-export side-effect honesty fix).
|
|
26
|
+
- Classifier: single early judgment for `require` / `dynamic-import` on layer edges.
|
|
27
|
+
- R6 honesty: impure value-export initializers (`export const db = connect()`) count as
|
|
28
|
+
top-level side effects — named type imports of those modules stay **judgment**.
|
|
29
|
+
- R6 honesty: non-exported impure top-level initializers (`const boot = setup()`) and
|
|
30
|
+
non-exported class static field calls also count as side effects (same skip-on-import-type risk).
|
|
31
|
+
- Codex home: single `upsertCodexMcpTable` path for primary and secondary MCP tables.
|
|
6
32
|
|
|
7
33
|
## 2.7.0 — 2026-07-09
|
|
8
34
|
|
package/bin/lib/agent-gates.mjs
CHANGED
|
@@ -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 /
|
|
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
|
|
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:
|
|
1411
|
-
tempPath:
|
|
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 (
|
|
1244
|
+
if (assessed.gap) {
|
|
1417
1245
|
gaps.push({
|
|
1418
|
-
id:
|
|
1419
|
-
severity:
|
|
1420
|
-
message:
|
|
1421
|
-
|
|
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
|
});
|
package/bin/lib/ast-scan.mjs
CHANGED
|
@@ -49,11 +49,15 @@ export function isTypeOnlyModuleReference(ts, node) {
|
|
|
49
49
|
* Used so static value-syntax `import { T }` of a pure-type module can be mechanical-safe
|
|
50
50
|
* (convert to `import type`). Never trust this for require()/import() edges.
|
|
51
51
|
*/
|
|
52
|
+
function hasExportModifier(ts, node) {
|
|
53
|
+
return (
|
|
54
|
+
Array.isArray(node.modifiers) &&
|
|
55
|
+
node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
export function sourceFileExportsOnlyTypes(ts, sourceFile) {
|
|
53
60
|
let sawTypeExport = false;
|
|
54
|
-
const hasExportModifier = (node) =>
|
|
55
|
-
Array.isArray(node.modifiers) &&
|
|
56
|
-
node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
57
61
|
|
|
58
62
|
for (const stmt of sourceFile.statements) {
|
|
59
63
|
// Type-only imports OK; value or side-effect imports mean runtime load of deps.
|
|
@@ -84,7 +88,7 @@ export function sourceFileExportsOnlyTypes(ts, sourceFile) {
|
|
|
84
88
|
}
|
|
85
89
|
if (ts.isExportAssignment(stmt)) return false; // export = / export default expr
|
|
86
90
|
if (ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) {
|
|
87
|
-
if (hasExportModifier(stmt)) sawTypeExport = true;
|
|
91
|
+
if (hasExportModifier(ts, stmt)) sawTypeExport = true;
|
|
88
92
|
continue;
|
|
89
93
|
}
|
|
90
94
|
// Any other top-level statement (const/fn/class/enum, console.log, if, …) is runtime.
|
|
@@ -93,6 +97,230 @@ export function sourceFileExportsOnlyTypes(ts, sourceFile) {
|
|
|
93
97
|
return sawTypeExport;
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Names that exist in the *value* export space of this module (runtime bindings).
|
|
102
|
+
* Used to subtract dual-space names (e.g. `export type Foo` + `export const Foo`) from
|
|
103
|
+
* type-only export sets so converting `import { Foo }` to `import type` never drops a
|
|
104
|
+
* runtime binding.
|
|
105
|
+
*/
|
|
106
|
+
function collectBindingIdentifiers(ts, nameNode, into) {
|
|
107
|
+
if (!nameNode) return;
|
|
108
|
+
if (ts.isIdentifier(nameNode)) {
|
|
109
|
+
into.add(nameNode.text);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (ts.isObjectBindingPattern(nameNode) || ts.isArrayBindingPattern(nameNode)) {
|
|
113
|
+
for (const el of nameNode.elements) {
|
|
114
|
+
if (ts.isOmittedExpression(el)) continue;
|
|
115
|
+
if (ts.isBindingElement(el)) collectBindingIdentifiers(ts, el.name, into);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function valueExportNames(ts, sourceFile) {
|
|
121
|
+
const names = new Set();
|
|
122
|
+
const add = (n) => {
|
|
123
|
+
if (n) names.add(n);
|
|
124
|
+
};
|
|
125
|
+
for (const stmt of sourceFile.statements) {
|
|
126
|
+
// export const/let/var Foo = …
|
|
127
|
+
if (ts.isVariableStatement(stmt) && hasExportModifier(ts, stmt)) {
|
|
128
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
129
|
+
collectBindingIdentifiers(ts, decl.name, names);
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// export function Foo / export async function Foo
|
|
134
|
+
if (ts.isFunctionDeclaration(stmt) && hasExportModifier(ts, stmt) && stmt.name) {
|
|
135
|
+
add(stmt.name.text);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// export class Foo — value + type space; treat as value so never auto import-type
|
|
139
|
+
if (ts.isClassDeclaration(stmt) && hasExportModifier(ts, stmt) && stmt.name) {
|
|
140
|
+
add(stmt.name.text);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// export enum Foo — value + type
|
|
144
|
+
if (ts.isEnumDeclaration(stmt) && hasExportModifier(ts, stmt) && stmt.name) {
|
|
145
|
+
add(stmt.name.text);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// export namespace Foo — value + type
|
|
149
|
+
if (ts.isModuleDeclaration(stmt) && hasExportModifier(ts, stmt) && stmt.name && ts.isIdentifier(stmt.name)) {
|
|
150
|
+
add(stmt.name.text);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!ts.isExportDeclaration(stmt)) continue;
|
|
154
|
+
// export * from '…' — unknown value surface; cannot prove type-only names alone
|
|
155
|
+
if (!stmt.exportClause) {
|
|
156
|
+
// star re-export can introduce values; flag as opaque by adding a sentinel? callers
|
|
157
|
+
// only check named bindings against explicit type-only sets — leave empty for star.
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ts.isNamespaceExport(stmt.exportClause)) continue;
|
|
161
|
+
if (!ts.isNamedExports(stmt.exportClause)) continue;
|
|
162
|
+
// bare `export { Foo }` / `export { Foo } from '…'` without type keyword — value (or dual)
|
|
163
|
+
if (!stmt.isTypeOnly) {
|
|
164
|
+
for (const el of stmt.exportClause.elements) {
|
|
165
|
+
if (el.isTypeOnly) continue;
|
|
166
|
+
const local = el.propertyName && 'text' in el.propertyName ? el.propertyName.text : el.name?.text;
|
|
167
|
+
add(local);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return names;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* True when an expression may run runtime work if the module is evaluated.
|
|
176
|
+
* Conservative: any call/new/await/tagged-template (or nested) is impure.
|
|
177
|
+
* Literals, identifiers, pure object/array/as/parenthesized trees are pure.
|
|
178
|
+
*/
|
|
179
|
+
export function expressionMayHaveSideEffects(ts, expr) {
|
|
180
|
+
if (!expr) return false;
|
|
181
|
+
if (
|
|
182
|
+
ts.isCallExpression(expr) ||
|
|
183
|
+
ts.isNewExpression(expr) ||
|
|
184
|
+
ts.isAwaitExpression(expr) ||
|
|
185
|
+
ts.isTaggedTemplateExpression(expr) ||
|
|
186
|
+
ts.isYieldExpression?.(expr)
|
|
187
|
+
) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
// Walk children; short-circuit on first impure.
|
|
191
|
+
let impure = false;
|
|
192
|
+
const visit = (node) => {
|
|
193
|
+
if (impure) return;
|
|
194
|
+
if (
|
|
195
|
+
ts.isCallExpression(node) ||
|
|
196
|
+
ts.isNewExpression(node) ||
|
|
197
|
+
ts.isAwaitExpression(node) ||
|
|
198
|
+
ts.isTaggedTemplateExpression(node) ||
|
|
199
|
+
(typeof ts.isYieldExpression === 'function' && ts.isYieldExpression(node))
|
|
200
|
+
) {
|
|
201
|
+
impure = true;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
ts.forEachChild(node, visit);
|
|
205
|
+
};
|
|
206
|
+
ts.forEachChild(expr, visit);
|
|
207
|
+
return impure;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* True when evaluating this module may run non-trivial top-level work.
|
|
212
|
+
* Covers: expression statements, bare side-effect imports, control-flow,
|
|
213
|
+
* any top-level var initializer that call/new/await (exported or not),
|
|
214
|
+
* export-default impure expr, and class static field calls (export-agnostic).
|
|
215
|
+
* Converting `import { Type }` → `import type` would skip those effects — not auto-safe.
|
|
216
|
+
*/
|
|
217
|
+
export function sourceFileHasTopLevelSideEffects(ts, sourceFile) {
|
|
218
|
+
for (const stmt of sourceFile.statements) {
|
|
219
|
+
if (ts.isExpressionStatement(stmt)) return true;
|
|
220
|
+
if (ts.isImportDeclaration(stmt) && !stmt.importClause) return true; // import './x'
|
|
221
|
+
if (
|
|
222
|
+
ts.isIfStatement(stmt) ||
|
|
223
|
+
ts.isForStatement(stmt) ||
|
|
224
|
+
ts.isForInStatement(stmt) ||
|
|
225
|
+
ts.isForOfStatement(stmt) ||
|
|
226
|
+
ts.isWhileStatement(stmt) ||
|
|
227
|
+
ts.isDoStatement(stmt) ||
|
|
228
|
+
ts.isSwitchStatement(stmt) ||
|
|
229
|
+
ts.isTryStatement(stmt) ||
|
|
230
|
+
ts.isThrowStatement(stmt) ||
|
|
231
|
+
ts.isWithStatement?.(stmt)
|
|
232
|
+
) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
// Top-level const/let/var x = <maybe impure> — including non-exported.
|
|
236
|
+
// `const db = connect(); export type Row = …` still runs connect on module load;
|
|
237
|
+
// converting `import { Row }` → `import type` would skip that work (R6 honesty).
|
|
238
|
+
if (ts.isVariableStatement(stmt)) {
|
|
239
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
240
|
+
if (decl.initializer && expressionMayHaveSideEffects(ts, decl.initializer)) return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// export default <expr>
|
|
244
|
+
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
|
|
245
|
+
if (stmt.expression && expressionMayHaveSideEffects(ts, stmt.expression)) return true;
|
|
246
|
+
}
|
|
247
|
+
// Class with static field initializers that call — class body evaluates at load
|
|
248
|
+
// whether or not the class is exported.
|
|
249
|
+
if (ts.isClassDeclaration(stmt)) {
|
|
250
|
+
for (const member of stmt.members ?? []) {
|
|
251
|
+
if (
|
|
252
|
+
ts.isPropertyDeclaration(member) &&
|
|
253
|
+
member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) &&
|
|
254
|
+
member.initializer &&
|
|
255
|
+
expressionMayHaveSideEffects(ts, member.initializer)
|
|
256
|
+
) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Names that are provably type-only exports of this module (erased at runtime).
|
|
267
|
+
* Conservative: class/enum/namespace/const/function exports are excluded even when they
|
|
268
|
+
* also introduce a type. Dual-space names (`export type Foo` + `export const Foo`) are
|
|
269
|
+
* subtracted — converting those to `import type` would drop a runtime binding.
|
|
270
|
+
* Used so `import { Row }` of a type alias from a mixed module can be mechanical-safe.
|
|
271
|
+
*/
|
|
272
|
+
export function typeOnlyExportNames(ts, sourceFile) {
|
|
273
|
+
const names = new Set();
|
|
274
|
+
for (const stmt of sourceFile.statements) {
|
|
275
|
+
if (ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) {
|
|
276
|
+
if (hasExportModifier(ts, stmt) && stmt.name) names.add(stmt.name.text);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!ts.isExportDeclaration(stmt)) continue;
|
|
280
|
+
const clause = stmt.exportClause;
|
|
281
|
+
if (!clause || !ts.isNamedExports(clause)) continue;
|
|
282
|
+
for (const el of clause.elements) {
|
|
283
|
+
// `export type { X }` or `export { type X }` — type-only re-exports.
|
|
284
|
+
if (stmt.isTypeOnly || el.isTypeOnly) {
|
|
285
|
+
const local = el.propertyName && 'text' in el.propertyName ? el.propertyName.text : el.name?.text;
|
|
286
|
+
if (local) names.add(local);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Subtract any name that also has a value export (dual-space / value re-export).
|
|
291
|
+
const values = valueExportNames(ts, sourceFile);
|
|
292
|
+
for (const v of values) names.delete(v);
|
|
293
|
+
return [...names];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Local names of named import/export bindings on a module edge, or null when the edge
|
|
298
|
+
* is not a pure named list (default import, namespace, side-effect, export *, export =).
|
|
299
|
+
* PropertyName is preferred so `import { Row as R }` still checks target export `Row`.
|
|
300
|
+
*/
|
|
301
|
+
export function namedModuleBindings(ts, node) {
|
|
302
|
+
if (ts.isImportDeclaration(node)) {
|
|
303
|
+
const clause = node.importClause;
|
|
304
|
+
if (!clause) return null; // side-effect
|
|
305
|
+
if (clause.name) return null; // default import (possibly with named — still not pure-named-only)
|
|
306
|
+
const named = clause.namedBindings;
|
|
307
|
+
if (!named || !ts.isNamedImports(named) || named.elements.length === 0) return null;
|
|
308
|
+
return named.elements.map((el) => {
|
|
309
|
+
const prop = el.propertyName && 'text' in el.propertyName ? el.propertyName.text : null;
|
|
310
|
+
return prop || el.name.text;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (ts.isExportDeclaration(node)) {
|
|
314
|
+
const clause = node.exportClause;
|
|
315
|
+
if (!clause || !ts.isNamedExports(clause) || clause.elements.length === 0) return null;
|
|
316
|
+
return clause.elements.map((el) => {
|
|
317
|
+
const prop = el.propertyName && 'text' in el.propertyName ? el.propertyName.text : null;
|
|
318
|
+
return prop || el.name.text;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
96
324
|
export function propertyName(ts, node) {
|
|
97
325
|
if (!node) return undefined;
|
|
98
326
|
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
|