arkgate 2.6.0 → 2.7.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 (57) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +98 -70
  3. package/bin/ark-check.mjs +240 -1001
  4. package/bin/ark-layer-match.mjs +153 -147
  5. package/bin/ark-mcp.mjs +102 -5
  6. package/bin/ark-shared.mjs +304 -165
  7. package/bin/ark.mjs +44 -34
  8. package/bin/lib/agent-gates.mjs +448 -15
  9. package/bin/lib/architecture-scan.mjs +279 -0
  10. package/bin/lib/ast-scan.mjs +199 -0
  11. package/bin/lib/baseline-key.mjs +23 -0
  12. package/bin/lib/config-warnings.mjs +228 -0
  13. package/bin/lib/doctor-plan.mjs +11 -4
  14. package/bin/lib/graph-cycles.mjs +56 -0
  15. package/bin/lib/presets.mjs +75 -4
  16. package/bin/lib/remediation.mjs +150 -0
  17. package/bin/lib/scan-files.mjs +69 -0
  18. package/bin/lib/ts-resolve.mjs +215 -0
  19. package/bin/lib/violations.mjs +3 -9
  20. package/dist/eslint/index.cjs +21 -3
  21. package/dist/eslint/index.cjs.map +1 -1
  22. package/dist/eslint/index.d.cts +5 -3
  23. package/dist/eslint/index.d.ts +5 -3
  24. package/dist/eslint/index.js +21 -3
  25. package/dist/eslint/index.js.map +1 -1
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +3 -3
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/nestjs/index.cjs +1 -1
  33. package/dist/nestjs/index.cjs.map +1 -1
  34. package/dist/nestjs/index.d.cts +1 -1
  35. package/dist/nestjs/index.d.ts +1 -1
  36. package/dist/nestjs/index.js +1 -1
  37. package/dist/nestjs/index.js.map +1 -1
  38. package/dist/runtime/index.cjs +3080 -0
  39. package/dist/runtime/index.cjs.map +1 -0
  40. package/dist/runtime/index.d.cts +2 -0
  41. package/dist/runtime/index.d.ts +2 -0
  42. package/dist/runtime/index.js +2998 -0
  43. package/dist/runtime/index.js.map +1 -0
  44. package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
  45. package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
  46. package/docs/agent-guide.md +67 -1
  47. package/docs/migrate-from-ark-runtime-kernel.md +4 -2
  48. package/docs/package-surface.md +72 -0
  49. package/docs/production-hardening.md +3 -0
  50. package/package.json +11 -1
  51. package/server.json +2 -2
  52. package/templates/skills/ark-adopt.md +43 -87
  53. package/templates/skills/ark-autopilot.md +39 -77
  54. package/templates/skills/ark-contract.md +43 -84
  55. package/templates/skills/ark-coverage.md +62 -83
  56. package/templates/skills/ark-fix.md +45 -90
  57. package/templates/skills/ark-loop.md +44 -66
@@ -1,168 +1,174 @@
1
1
  /**
2
- * Pure layer-glob matching for ark.config.json.
3
- * Single source of truth for CLI (ark-shared / ark-check) and ESLint (bundled via import).
4
- * No Node I/O beyond path.sep normalization — pure string/path math only.
2
+ * GENERATED FILE do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/layerMatch.ts
5
+ * Regenerate: node scripts/generate-layer-match.mjs
6
+ * Drift check: node scripts/generate-layer-match.mjs --check
7
+ *
8
+ * Pure layer-glob matching for ark.config.json (CLI load path).
9
+ * CLI-only layerForFile (Node path resolution) is appended below the pure core.
5
10
  */
6
- import path from 'node:path';
7
-
8
- const _regexpCache = new Map();
9
11
 
12
+ const regexpCache = new Map();
10
13
  function escapeLiteral(ch) {
11
- return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
14
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
12
15
  }
13
-
14
- /** True only when every `{` has a matching `}` (ignoring backslash-escaped braces). */
15
- function bracesBalanced(glob) {
16
- let depth = 0;
17
- for (let i = 0; i < glob.length; i += 1) {
18
- const c = glob[i];
19
- if (c === '\\') {
20
- i += 1; // skip the escaped character
21
- continue;
16
+ /**
17
+ * Normalize path separators to `/` without destroying glob escape sequences.
18
+ * `src\domain\x` → `src/domain/x` (Windows paths); `src/\{legacy\}/**` keeps `\{` / `\}`.
19
+ * A plain `pattern.split('\\').join('/')` would eat those escapes.
20
+ */
21
+ function normalizeGlobSeparators(pattern) {
22
+ let out = '';
23
+ for (let i = 0; i < pattern.length; i += 1) {
24
+ const c = pattern[i];
25
+ if (c === '\\' && i + 1 < pattern.length) {
26
+ const next = pattern[i + 1];
27
+ // Keep escapes for glob metacharacters (and escaped backslash).
28
+ if ('*?{}[],'.includes(next) || next === '\\') {
29
+ out += '\\' + next;
30
+ i += 1;
31
+ continue;
32
+ }
33
+ // Otherwise treat `\` as a path separator (Windows).
34
+ out += '/';
35
+ continue;
36
+ }
37
+ out += c;
22
38
  }
23
- if (c === '{') depth += 1;
24
- else if (c === '}') {
25
- depth -= 1;
26
- if (depth < 0) return false;
39
+ return out;
40
+ }
41
+ function bracesBalanced(glob) {
42
+ let depth = 0;
43
+ for (let i = 0; i < glob.length; i += 1) {
44
+ const c = glob[i];
45
+ if (c === '\\') {
46
+ i += 1;
47
+ continue;
48
+ }
49
+ if (c === '{')
50
+ depth += 1;
51
+ else if (c === '}') {
52
+ depth -= 1;
53
+ if (depth < 0)
54
+ return false;
55
+ }
27
56
  }
28
- }
29
- return depth === 0;
57
+ return depth === 0;
30
58
  }
31
-
32
- /**
33
- * Convert an ark.config.json layer glob pattern to an anchored RegExp (compiled once per
34
- * pattern, then cached).
35
- *
36
- * IMPORTANT: the double-star is expanded in a SINGLE pass. A chained two-step replace
37
- * (double-star to dot-star, then single-star to a no-slash class) corrupts the double-star,
38
- * because the second step re-matches the star inside the substitution the first step just
39
- * inserted. That made "src/kernel/**" stop matching nested paths, silently unclassifying
40
- * every file in a subdirectory. Scanning one character at a time also lets us support
41
- * brace alternation ("*.{ts,tsx}") and backslash escapes ("\\{" → literal brace).
42
- *
43
- * Brace alternation is only enabled when braces are balanced; an unbalanced brace (a config
44
- * typo) is treated as a literal so the gate never crashes on `new RegExp`.
45
- */
46
59
  export function globToRegExp(pattern) {
47
- const cached = _regexpCache.get(pattern);
48
- if (cached) return cached;
49
-
50
- const glob = pattern.split(path.sep).join('/');
51
- const useBraces = bracesBalanced(glob);
52
- let out = '';
53
- let braceDepth = 0;
54
- for (let i = 0; i < glob.length; i += 1) {
55
- const c = glob[i];
56
- if (c === '\\' && i + 1 < glob.length) {
57
- out += escapeLiteral(glob[i + 1]); // backslash escapes the next char to a literal
58
- i += 1;
59
- } else if (c === '*') {
60
- if (glob[i + 1] === '*') {
61
- if (glob[i + 2] === '/') {
62
- out += '(?:.*/)?'; // `**/` matches zero or more path segments
63
- i += 2;
64
- } else {
65
- out += '.*'; // `**` matches across `/`
66
- i += 1;
60
+ const cached = regexpCache.get(pattern);
61
+ if (cached)
62
+ return cached;
63
+ const glob = normalizeGlobSeparators(pattern);
64
+ const useBraces = bracesBalanced(glob);
65
+ let out = '';
66
+ let braceDepth = 0;
67
+ for (let i = 0; i < glob.length; i += 1) {
68
+ const c = glob[i];
69
+ if (c === '\\' && i + 1 < glob.length) {
70
+ out += escapeLiteral(glob[i + 1]);
71
+ i += 1;
72
+ }
73
+ else if (c === '*') {
74
+ if (glob[i + 1] === '*') {
75
+ if (glob[i + 2] === '/') {
76
+ out += '(?:.*/)?';
77
+ i += 2;
78
+ }
79
+ else {
80
+ out += '.*';
81
+ i += 1;
82
+ }
83
+ }
84
+ else {
85
+ out += '[^/]*';
86
+ }
87
+ }
88
+ else if (c === '?') {
89
+ out += '[^/]';
90
+ }
91
+ else if (c === '{' && useBraces) {
92
+ out += '(?:';
93
+ braceDepth += 1;
94
+ }
95
+ else if (c === '}' && useBraces && braceDepth > 0) {
96
+ out += ')';
97
+ braceDepth -= 1;
98
+ }
99
+ else if (c === ',' && useBraces && braceDepth > 0) {
100
+ out += '|';
101
+ }
102
+ else {
103
+ out += escapeLiteral(c);
67
104
  }
68
- } else {
69
- out += '[^/]*'; // `*` matches within a single segment
70
- }
71
- } else if (c === '?') {
72
- out += '[^/]';
73
- } else if (c === '{' && useBraces) {
74
- out += '(?:';
75
- braceDepth += 1;
76
- } else if (c === '}' && useBraces && braceDepth > 0) {
77
- out += ')';
78
- braceDepth -= 1;
79
- } else if (c === ',' && useBraces && braceDepth > 0) {
80
- out += '|';
81
- } else {
82
- out += escapeLiteral(c);
83
105
  }
84
- }
85
- const re = new RegExp(`^${out}$`);
86
- _regexpCache.set(pattern, re);
87
- return re;
106
+ const re = new RegExp(`^${out}$`);
107
+ regexpCache.set(pattern, re);
108
+ return re;
88
109
  }
89
-
90
- // Specificity score for a layer glob: more literal path segments before the first wildcard
91
- // wins, then longer literal text. So `src/kernel/app/**` (3 literal segments) beats
92
- // `src/kernel/**` (2), and an exact file like `src/kernel/events.ts` beats both. This is what
93
- // makes a facade split (a KernelApi surface layer overlapping a KernelInternal catch-all)
94
- // resolve to the surface REGARDLESS of layer declaration order — the intuitive result.
95
110
  export function patternSpecificity(pattern) {
96
- const glob = String(pattern).split(path.sep).join('/');
97
- const beforeWildcard = glob.split('*')[0];
98
- const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
99
- const literalLength = glob.replace(/\*/g, '').length;
100
- return literalSegments * 10000 + literalLength;
111
+ const glob = normalizeGlobSeparators(String(pattern));
112
+ const beforeWildcard = glob.split('*')[0];
113
+ const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
114
+ const literalLength = glob.replace(/\*/g, '').length;
115
+ return literalSegments * 10000 + literalLength;
101
116
  }
102
-
103
- /**
104
- * Resolve a file's architecture layer from ark.config.json layer glob patterns. When more
105
- * than one layer matches (overlapping globs, e.g. a facade split), the MOST SPECIFIC pattern
106
- * wins; ties break by declaration order (first wins). Order-independent for non-ambiguous
107
- * overlaps, so a config author can't silently break a facade by listing the catch-all first.
108
- *
109
- * A layer may also declare `exclude` globs. A file matching ANY exclude glob is NOT a
110
- * candidate for that layer even if a `patterns` glob matches — this lets a broad pattern
111
- * (e.g. `src/**​/domain/**`) carve out subtrees it should not govern (framework internals
112
- * like `**​/kernel/**`) without enumerating every include. Excluding a file from its layer
113
- * also removes it from that layer's rule and `forbiddenGlobals` enforcement, since both key
114
- * off this classification — which is exactly how a broad domain glob stops mis-flagging
115
- * `src/kernel/domain` as impure domain code. This is the single file→layer matcher shared by
116
- * the ark-check CI gate and the ark-mcp write gate, so `exclude` behaves identically in both.
117
- */
118
- export function layerForFile(root, file, layers) {
119
- const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
120
- const rel = path.relative(root, abs).split(path.sep).join('/');
121
- let bestName;
122
- let bestScore = -1;
123
- for (const layer of layers ?? []) {
124
- if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
125
- continue;
126
- }
127
- for (const pattern of layer.patterns ?? []) {
128
- if (globToRegExp(pattern).test(rel)) {
129
- const score = patternSpecificity(pattern);
130
- if (score > bestScore) {
131
- bestScore = score;
132
- bestName = layer.name;
117
+ export function layerForRelativePath(relPath, layers) {
118
+ // File paths (not globs): any OS separator → posix relative.
119
+ const rel = String(relPath).split(/[/\\]/).join('/');
120
+ let bestName;
121
+ let bestScore = -1;
122
+ for (const layer of layers ?? []) {
123
+ if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
124
+ continue;
125
+ }
126
+ for (const pattern of layer.patterns ?? []) {
127
+ if (globToRegExp(pattern).test(rel)) {
128
+ const score = patternSpecificity(pattern);
129
+ if (score > bestScore) {
130
+ bestScore = score;
131
+ bestName = layer.name;
132
+ }
133
+ }
133
134
  }
134
- }
135
135
  }
136
- }
137
- return bestName;
136
+ return bestName;
137
+ }
138
+ export function isEdgeDenied(rules, from, to) {
139
+ if (from === to)
140
+ return false;
141
+ const hit = (rules ?? []).find((r) => r.from === from && r.to === to);
142
+ return hit?.allowed === false;
143
+ }
144
+ /** Codegen globs skipped by default scan (emitted into the CLI derived matcher). */
145
+ export const DEFAULT_GENERATED_FILE_GLOBS = [
146
+ '**/*.gen.ts',
147
+ '**/*.gen.tsx',
148
+ '**/*.generated.ts',
149
+ '**/*.generated.tsx',
150
+ ];
151
+ export function scanExcludePatterns(config) {
152
+ const custom = Array.isArray(config?.exclude)
153
+ ? config.exclude.filter((p) => typeof p === 'string')
154
+ : [];
155
+ const generated = config?.excludeGenerated === false ? [] : DEFAULT_GENERATED_FILE_GLOBS;
156
+ return [...generated, ...custom];
157
+ }
158
+ export function isScanExcludedRelative(relPath, config) {
159
+ const rel = String(relPath).split(/[/\\]/).join('/');
160
+ return scanExcludePatterns(config).some((pattern) => globToRegExp(pattern).test(rel));
138
161
  }
139
162
 
140
163
 
141
- /** Classify a project-relative path (posix) without needing an absolute root. */
142
- export function layerForRelativePath(relPath, layers) {
143
- const rel = String(relPath).split(path.sep).join('/');
144
- let bestName;
145
- let bestScore = -1;
146
- for (const layer of layers ?? []) {
147
- if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
148
- continue;
149
- }
150
- for (const pattern of layer.patterns ?? []) {
151
- if (globToRegExp(pattern).test(rel)) {
152
- const score = patternSpecificity(pattern);
153
- if (score > bestScore) {
154
- bestScore = score;
155
- bestName = layer.name;
156
- }
157
- }
158
- }
159
- }
160
- return bestName;
161
- }
164
+ import path from 'node:path';
162
165
 
163
- /** True when rules[] explicitly deny from→to. Missing rule = allowed (implicit). */
164
- export function isEdgeDenied(rules, from, to) {
165
- if (from === to) return false;
166
- const hit = (rules ?? []).find((r) => r.from === from && r.to === to);
167
- return hit?.allowed === false;
166
+ /**
167
+ * Resolve a file's architecture layer from ark.config.json layer glob patterns.
168
+ * Uses Node path resolution, then the pure layerForRelativePath classifier.
169
+ */
170
+ export function layerForFile(root, file, layers) {
171
+ const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
172
+ const rel = path.relative(root, abs).split(path.sep).join('/');
173
+ return layerForRelativePath(rel, layers);
168
174
  }
package/bin/ark-mcp.mjs CHANGED
@@ -41,6 +41,9 @@ import {
41
41
  arkCommand,
42
42
  layerForFile,
43
43
  shouldShowNewHereNudge,
44
+ detectWorkspaces,
45
+ detectTsPackageRoots,
46
+ resolveIncludeRoots,
44
47
  } from './ark-shared.mjs';
45
48
 
46
49
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
@@ -576,9 +579,9 @@ async function main() {
576
579
  {
577
580
  name: 'ark_place',
578
581
  description:
579
- 'Given a target file path, return which layer it belongs to, which layers it may and ' +
580
- 'must NOT import, and its forbidden globals so generated code lands in a governed ' +
581
- 'location with the right dependencies. Call this BEFORE writing a new file.',
582
+ 'Place a file in the architecture: pass filePath (preferred) and/or description. ' +
583
+ 'Returns layer, mayImport / mustNotImport, forbiddenGlobals. Call BEFORE writing a new file. ' +
584
+ 'If only description is given, returns a conventional path proposal under a governed layer.',
582
585
  inputSchema: {
583
586
  type: 'object',
584
587
  properties: {
@@ -586,8 +589,12 @@ async function main() {
586
589
  type: 'string',
587
590
  description: 'Path (relative to project root or absolute) of the file to place.',
588
591
  },
592
+ description: {
593
+ type: 'string',
594
+ description:
595
+ 'What you are building (e.g. "Remotion caption overlay"). Used when filePath is omitted to propose a path.',
596
+ },
589
597
  },
590
- required: ['filePath'],
591
598
  },
592
599
  },
593
600
  {
@@ -599,6 +606,14 @@ async function main() {
599
606
  'Call BEFORE generating project structure on greenfield or early-adoption repos.',
600
607
  inputSchema: { type: 'object', properties: {} },
601
608
  },
609
+ {
610
+ name: 'ark_suggest_include',
611
+ description:
612
+ 'Propose ark.config.json include roots from workspaces and nested TypeScript packages ' +
613
+ '(polyglot-safe). Same idea as ark-check --suggest-include. Use when coverage is empty ' +
614
+ 'or the contract misses package roots.',
615
+ inputSchema: { type: 'object', properties: {} },
616
+ },
602
617
  ];
603
618
 
604
619
  const RESOURCES = [
@@ -741,8 +756,52 @@ async function main() {
741
756
  // `allowed:false` denies) — which layers it may and must not import.
742
757
  function runPlace(params) {
743
758
  const filePath = params?.arguments?.filePath;
759
+ const description = params?.arguments?.description;
760
+ if ((typeof filePath !== 'string' || !filePath) && typeof description === 'string' && description.trim()) {
761
+ // Description-only: propose a governed path under PresentationAdapters (UI default).
762
+ const slug = description
763
+ .trim()
764
+ .toLowerCase()
765
+ .replace(/[^a-z0-9]+/g, '-')
766
+ .replace(/^-|-$/g, '')
767
+ .slice(0, 48) || 'component';
768
+ const proposedPath = `src/components/${slug}.tsx`;
769
+ const layerName = inferLayer(proposedPath, config, args.root) || 'PresentationAdapters';
770
+ return {
771
+ content: [
772
+ {
773
+ type: 'text',
774
+ text: JSON.stringify(
775
+ {
776
+ filePath: proposedPath,
777
+ proposed: true,
778
+ description: description.trim(),
779
+ layer: layerName,
780
+ governed: Boolean(inferLayer(proposedPath, config, args.root)),
781
+ note:
782
+ 'filePath was omitted — proposed a conventional path from description. ' +
783
+ 'Pass filePath explicitly for authoritative placement. Then validate_code the snippet.',
784
+ },
785
+ null,
786
+ 2
787
+ ),
788
+ },
789
+ ],
790
+ isError: false,
791
+ };
792
+ }
744
793
  if (typeof filePath !== 'string' || !filePath) {
745
- return { content: [{ type: 'text', text: 'Missing required "filePath" argument.' }], isError: true };
794
+ return {
795
+ content: [
796
+ {
797
+ type: 'text',
798
+ text:
799
+ 'ark_place needs filePath and/or description. ' +
800
+ 'Example: { "filePath": "src/components/Foo.tsx" } or { "description": "caption overlay UI component" }.',
801
+ },
802
+ ],
803
+ isError: true,
804
+ };
746
805
  }
747
806
  const layerName = inferLayer(filePath, config, args.root);
748
807
  if (!layerName) {
@@ -814,12 +873,50 @@ async function main() {
814
873
  };
815
874
  }
816
875
 
876
+ function runSuggestIncludeTool() {
877
+ try {
878
+ const workspaces = detectWorkspaces(args.root);
879
+ const tsPackages = detectTsPackageRoots(args.root);
880
+ const suggestedInclude = resolveIncludeRoots(args.root);
881
+ return {
882
+ content: [
883
+ {
884
+ type: 'text',
885
+ text: JSON.stringify(
886
+ {
887
+ ok: true,
888
+ workspaces,
889
+ tsPackages,
890
+ suggestedInclude:
891
+ suggestedInclude.length > 0
892
+ ? suggestedInclude
893
+ : tsPackages.length > 0
894
+ ? tsPackages
895
+ : ['src'],
896
+ next: 'npx ark-check --adopt-contract --write',
897
+ },
898
+ null,
899
+ 2
900
+ ),
901
+ },
902
+ ],
903
+ isError: false,
904
+ };
905
+ } catch (error) {
906
+ return {
907
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
908
+ isError: true,
909
+ };
910
+ }
911
+ }
912
+
817
913
  const TOOL_HANDLERS = {
818
914
  validate_code: runValidate,
819
915
  ark_check: runCheckTool,
820
916
  ark_coverage: runCoverageTool,
821
917
  ark_place: runPlace,
822
918
  ark_recommend: runRecommendTool,
919
+ ark_suggest_include: runSuggestIncludeTool,
823
920
  };
824
921
 
825
922
  const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);