arkgate 4.8.9 → 4.8.11

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 (53) hide show
  1. package/CHANGELOG.md +111 -3
  2. package/README.md +5 -4
  3. package/bin/ark-check-runtime.mjs +8 -2
  4. package/bin/ark-check.mjs +3 -0
  5. package/bin/ark.mjs +18 -10
  6. package/bin/lib/adapter-contract-types.mjs +137 -0
  7. package/bin/lib/adapter-contract.mjs +4 -180
  8. package/bin/lib/adapter-finding-refs.mjs +63 -0
  9. package/bin/lib/agent-projection-formatters.mjs +151 -0
  10. package/bin/lib/agent-projection-merge.mjs +148 -0
  11. package/bin/lib/agent-projection-types.mjs +42 -0
  12. package/bin/lib/agent-projection.mjs +3 -309
  13. package/bin/lib/analysis-engine.mjs +6 -6
  14. package/bin/lib/architecture-scan.mjs +91 -4
  15. package/bin/lib/ark-order-facts.mjs +11 -4
  16. package/bin/lib/arkrule-file-hints.mjs +255 -20
  17. package/bin/lib/arkrules-sensors.mjs +364 -68
  18. package/bin/lib/baseline-key.mjs +45 -1
  19. package/bin/lib/config-contract.mjs +9 -3
  20. package/bin/lib/diagnostic-catalog.mjs +1 -0
  21. package/bin/lib/doctor-human.mjs +3 -3
  22. package/bin/lib/doctor-next-actions.mjs +3 -1
  23. package/bin/lib/field-install.mjs +23 -2
  24. package/bin/lib/first-run-help.mjs +69 -5
  25. package/bin/lib/project-root.mjs +70 -4
  26. package/bin/lib/resolved-candidate-facts.mjs +82 -1
  27. package/bin/lib/rules-inventory.mjs +7 -3
  28. package/bin/lib/upstream-report.mjs +330 -0
  29. package/bin/lib/violations.mjs +51 -15
  30. package/dist/{diagnosticCatalog-BrkOiwCk.d.ts → diagnosticCatalog-DiflIock.d.ts} +30 -41
  31. package/dist/eslint/index.cjs +4 -7
  32. package/dist/eslint/index.js +4 -7
  33. package/dist/index.cjs +28 -31
  34. package/dist/index.d.ts +52 -13
  35. package/dist/index.js +28 -31
  36. package/dist/nestjs/index.cjs +1 -1
  37. package/dist/nestjs/index.js +1 -1
  38. package/dist/runtime/index.cjs +11 -11
  39. package/dist/runtime/index.d.ts +1 -1
  40. package/dist/runtime/index.js +11 -11
  41. package/docs/README.md +3 -3
  42. package/docs/agent-guide.md +25 -1
  43. package/docs/ai-gates.md +8 -0
  44. package/docs/brownfield-adoption.md +30 -0
  45. package/docs/configuration.md +14 -1
  46. package/docs/diagnostics.md +10 -0
  47. package/docs/package-surface.md +5 -3
  48. package/docs/use.md +11 -0
  49. package/package.json +3 -2
  50. package/schemas/ark.config.schema.json +3 -2
  51. package/server.json +2 -2
  52. package/templates/agent-skills/ark-explore/SKILL.md +22 -1
  53. package/templates/skills/ark-explore.md +22 -1
@@ -116,7 +116,9 @@ export function collectDoctorNextActions(ctx) {
116
116
  actions.push('review dirty baseline freezes — fix the contract before trusting green-via-freeze');
117
117
  }
118
118
  if (ctx.analysisComplete && ctx.staleBaseline > 0) {
119
- actions.push('tighten the baseline (--update-baseline)');
119
+ actions.push(
120
+ 'tighten the baseline (--update-baseline --force --contract-session --author <steward>)'
121
+ );
120
122
  }
121
123
  if (ctx.staleRunners.length > 0) {
122
124
  actions.push(
@@ -83,6 +83,27 @@ function addDevDependencyPreservingFormat(source, version) {
83
83
  return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
84
84
  }
85
85
 
86
+ const ARK_CHECK_BIN_RE = /\b(?:ark-check|arkgate-check)(?:\.mjs|\.js)?\b/;
87
+ const ARK_CHECK_RUNNER_RE =
88
+ /(?:^|[\s"'`;|&])(?:npx|pnpm|yarn|npm|bunx?|node)(?:\s|$)/;
89
+ const GITHUB_RUN_KEY_RE = /^\s*(?:-\s+)?run:\s+/;
90
+ const YAML_CHECK_JOB_ID_RE =
91
+ /^\s*(?:-\s+)?['"]?(?:ark-check|arkgate-check)['"]?\s*:/;
92
+ const YAML_CONCURRENCY_GROUP_RE = /^\s*group:\s+/;
93
+
94
+ /**
95
+ * True when the line invokes ark-check / arkgate-check (npx/pnpm/yarn/npm/node/run).
96
+ * YAML concurrency.group and job-id keys that only contain the name are not invocations.
97
+ */
98
+ export function isArkCheckInvocationLine(command) {
99
+ if (typeof command !== 'string' || !command.trim()) return false;
100
+ if (/^\s*#/.test(command)) return false;
101
+ if (YAML_CHECK_JOB_ID_RE.test(command)) return false;
102
+ if (YAML_CONCURRENCY_GROUP_RE.test(command)) return false;
103
+ if (!ARK_CHECK_BIN_RE.test(command)) return false;
104
+ return ARK_CHECK_RUNNER_RE.test(command) || GITHUB_RUN_KEY_RE.test(command);
105
+ }
106
+
86
107
  /**
87
108
  * Ensure a check command string includes `--baseline <file>`.
88
109
  * Only touches strings that already invoke ark-check / arkgate-check.
@@ -97,7 +118,7 @@ export function ensureBaselineFlagInCheckCommand(
97
118
  if (/^\s*#/.test(command)) {
98
119
  return { command, changed: false };
99
120
  }
100
- if (!/\b(ark-check|arkgate-check)\b/.test(command)) {
121
+ if (!isArkCheckInvocationLine(command)) {
101
122
  return { command, changed: false };
102
123
  }
103
124
  if (/(?:^|\s)--baseline(?:\s|=|$)/.test(command)) {
@@ -180,7 +201,7 @@ export function syncBaselineIntoCheckSurfaces(root, opts = {}) {
180
201
  let fileChanged = false;
181
202
  const nextLines = lines.map((line) => {
182
203
  if (/^\s*#/.test(line)) return line;
183
- if (!/\b(ark-check|arkgate-check)\b/.test(line)) return line;
204
+ if (!isArkCheckInvocationLine(line)) return line;
184
205
  if (/(?:^|\s)--baseline(?:\s|=|$)/.test(line)) return line;
185
206
  const { command, changed: c } = ensureBaselineFlagInCheckCommand(line, flagRel);
186
207
  if (c) {
@@ -95,12 +95,74 @@ Non-interactive (no TTY): uses the same defaults as --yes — never calls readli
95
95
  `;
96
96
  }
97
97
 
98
+ /** `--sensors` is contract + coverage-evidence only — never a full-check pass. */
99
+ export const SENSORS_PARTIAL_MODE_LINE =
100
+ 'Contract + coverage-evidence only: no TypeScript, no analysis. Not a validity verdict.';
101
+
102
+ export const SENSORS_DID_NOT_RUN = Object.freeze(['TypeScript', 'analysis']);
103
+
104
+ /**
105
+ * Stamp a successful `--sensors --json` payload so agents cannot read exit 0 as
106
+ * a full-check pass. Failure payloads (`sensors.ok === false`) stay untouched.
107
+ */
108
+ export function stampSensorsPartialModePayload(payload) {
109
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
110
+ const sensors = payload.sensors;
111
+ if (!sensors || typeof sensors !== 'object' || Array.isArray(sensors)) return payload;
112
+ if (sensors.ok === false) return payload;
113
+ return {
114
+ ...payload,
115
+ sensors: {
116
+ ...sensors,
117
+ notAVerdict: true,
118
+ didNotRun: [...SENSORS_DID_NOT_RUN],
119
+ partialMode: 'contract-only',
120
+ },
121
+ };
122
+ }
123
+
124
+ /**
125
+ * After `runSensors`, name what this mode skipped. Human success prints the
126
+ * line on stdout; JSON success restamps the captured object. Failures reprint
127
+ * as-is so exit 2 does not look like a map.
128
+ */
129
+ export async function withSensorsPartialModeHonesty(args, run) {
130
+ if (args?.json) {
131
+ const chunks = [];
132
+ const original = console.log;
133
+ console.log = (...parts) => {
134
+ chunks.push(parts.map(String).join(' '));
135
+ };
136
+ try {
137
+ await run();
138
+ } finally {
139
+ console.log = original;
140
+ }
141
+ const text = chunks.join('\n');
142
+ if ((process.exitCode ?? 0) !== 0) {
143
+ if (text) original(text);
144
+ return;
145
+ }
146
+ try {
147
+ original(JSON.stringify(stampSensorsPartialModePayload(JSON.parse(text)), null, 2));
148
+ } catch {
149
+ original(text);
150
+ }
151
+ return;
152
+ }
153
+ await run();
154
+ if ((process.exitCode ?? 0) === 0) {
155
+ console.log(SENSORS_PARTIAL_MODE_LINE);
156
+ }
157
+ }
158
+
98
159
  export function checkUsage() {
99
160
  return [
100
161
  'arkgate-check (alias ark-check) — the architecture check.',
101
162
  '',
102
163
  ' arkgate-check --doctor where you are: one status light, one next action',
103
164
  ' arkgate-check --strict-merge CI / merge gate (required GitHub status)',
165
+ ' arkgate-check --sensors which sensors can ever be enforced (does not run analysis)',
104
166
  '',
105
167
  'Every flag and command: arkgate-check --help --all',
106
168
  ].join('\n');
@@ -124,7 +186,7 @@ export function checkUsageAll() {
124
186
  ' Exit 0 ran and clean, 1 drift remains, 2 could not run (no usable base ref).',
125
187
  ' ark-check --sensors [--json] every sensor with its tier and whether it can EVER be enforced, plus every declared rule',
126
188
  ' with its local id, the sensor it delegates to, its source file, its mode and why it can or cannot be promoted.',
127
- ' Contract + coverage-evidence only: no TypeScript, no analysis. Exit 0 on a report, 2 if the contract will not load.',
189
+ ` ${SENSORS_PARTIAL_MODE_LINE} Exit 0 on a report, 2 if the contract will not load.`,
128
190
  ' ark-check --promote [<ruleId>] [--json] [--apply]',
129
191
  ' what enforcing would cost: the findings each advisory rule already produces, from ONE run rather than one run per attempt.',
130
192
  ' Plan by default; --promote <ruleId> --apply (or --promote=<ruleId>) writes mode "enforced" into the ArkRules file that declares it.',
@@ -143,12 +205,14 @@ export function checkUsageAll() {
143
205
  ' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|vite-vercel-spa|clean-architecture|onion-architecture] [--force] [--follow-config-root]',
144
206
  ' --follow-config-root On writes (init/install-agent-gates/migrate --write/…), adopt walked-up monorepo config root (default: keep explicit --root)',
145
207
  ' ark-check --install-agent-gates [--tools claude,cursor,codex,grok,antigravity] [--require-write-hook <host>] [--skills-only] [--codex-home] [--claude-home] [--grok-home] [--antigravity-home] [--agent-homes] [--force]',
146
- ' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
208
+ ' ark-check --update-baseline [file] --force --contract-session --author <steward>',
209
+ ' freeze current violations (default .ark-baseline.json). --contract-session is required;',
210
+ ' --force when freeze-refuse fires; --author when stewards[] is set.',
147
211
  ' ark-check --print-config eleven-layer',
148
212
  '',
149
- 'Adopting Ark in an existing codebase? Run --update-baseline once to freeze existing',
150
- 'violations, commit the baseline file, and gate CI with --baseline: only NEW violations',
151
- 'fail the check, so the ratchet only moves toward zero.',
213
+ 'Adopting Ark in an existing codebase? Run --update-baseline --force --contract-session --author <steward>',
214
+ 'once to freeze existing violations, commit the baseline file, and gate CI with --baseline: only NEW',
215
+ 'violations fail the check, so the ratchet only moves toward zero.',
152
216
  '',
153
217
  'Team parliament: law files (ark.config / arkrules / .ark-baseline.json) cannot ship in',
154
218
  'the same diff as product source. --changed --base <ref> checks touched files only.',
@@ -88,10 +88,64 @@ export function resolveConfigPathWithinRoot(projectRoot, configPathOrName) {
88
88
  return { ok: true, configPath };
89
89
  }
90
90
 
91
+ function isFile(absPath) {
92
+ try {
93
+ return Boolean(fs.statSync(absPath, { throwIfNoEntry: false })?.isFile());
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /** True when --config is a path (nested or absolute), not a basename to walk. */
100
+ export function configNameIsPath(configName) {
101
+ return (
102
+ typeof configName === 'string' &&
103
+ (path.isAbsolute(configName) || /[\\/]/.test(configName))
104
+ );
105
+ }
106
+
107
+ function isInsideRoot(root, target) {
108
+ const rel = path.relative(root, target);
109
+ return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
110
+ }
111
+
112
+ /**
113
+ * Resolve a nested/absolute --config path without walking parents.
114
+ * `--root examples/app --config examples/app/ark.config.json` must load the nested
115
+ * contract, not latch onto a parent basename walk.
116
+ *
117
+ * @param {string} startDir
118
+ * @param {string} configName
119
+ * @returns {string | null} absolute config file path
120
+ */
121
+ export function resolveConfigPathCandidate(startDir, configName) {
122
+ if (typeof configName !== 'string' || configName.trim() === '') return null;
123
+ const start = path.resolve(startDir || process.cwd());
124
+ if (path.isAbsolute(configName)) return isFile(configName) ? path.resolve(configName) : null;
125
+
126
+ const fromStart = path.resolve(start, configName);
127
+ if (isFile(fromStart)) return fromStart;
128
+
129
+ const base = path.basename(configName);
130
+ const relDir = path.dirname(configName);
131
+ if (relDir && relDir !== '.') {
132
+ const namedLeaf = path.basename(relDir);
133
+ if (namedLeaf && namedLeaf === path.basename(start)) {
134
+ const stripped = path.join(start, base);
135
+ if (isFile(stripped)) return stripped;
136
+ }
137
+ }
138
+
139
+ const fromCwd = path.resolve(process.cwd(), configName);
140
+ if (fromCwd !== fromStart && isFile(fromCwd)) return fromCwd;
141
+ return null;
142
+ }
143
+
91
144
  /**
92
145
  * Walk parents from startDir looking for configName (default ark.config.json).
93
146
  * Bounds: filesystem root, max depth, git root, workspaces package root.
94
147
  * Config found at a bound root is accepted; walking above a bound is refused.
148
+ * Nested relative --config (`examples/app/ark.config.json`) is a file path, never a walk-up name.
95
149
  *
96
150
  * @param {string} startDir
97
151
  * @param {string} [configName='ark.config.json']
@@ -104,17 +158,29 @@ export function findNearestArkConfig(startDir, configName = 'ark.config.json', o
104
158
  const boundAtWorkspaces = opts.boundAtWorkspacesRoot !== false;
105
159
 
106
160
  if (typeof configName === 'string' && path.isAbsolute(configName)) {
107
- if (fs.existsSync(configName)) {
161
+ if (isFile(configName)) {
108
162
  const root = path.dirname(configName);
109
163
  const start = path.resolve(startDir || process.cwd());
110
- return { root, configPath: configName, walkedUp: path.resolve(root) !== start };
164
+ return { root, configPath: path.resolve(configName), walkedUp: path.resolve(root) !== start };
111
165
  }
112
166
  return null;
113
167
  }
114
168
 
169
+ const name = configName || 'ark.config.json';
170
+ if (configNameIsPath(name)) {
171
+ const resolved = resolveConfigPathCandidate(startDir, name);
172
+ if (!resolved) return null;
173
+ const start = path.resolve(startDir || process.cwd());
174
+ const inside = isInsideRoot(start, resolved);
175
+ return {
176
+ root: inside ? start : path.dirname(resolved),
177
+ configPath: resolved,
178
+ walkedUp: !inside,
179
+ };
180
+ }
181
+
115
182
  let dir = path.resolve(startDir || process.cwd());
116
183
  const start = dir;
117
- const name = configName || 'ark.config.json';
118
184
  let depth = 0;
119
185
  for (;;) {
120
186
  const candidate = path.join(dir, name);
@@ -231,7 +297,7 @@ export function resolveEffectiveProjectRoot(startRoot, opts = {}) {
231
297
  config: configName,
232
298
  configPath: found.configPath,
233
299
  configRoot: found.root,
234
- walkedUp: true,
300
+ walkedUp: found.walkedUp,
235
301
  configFound: true,
236
302
  writeRootFollowedConfig: adoptWalkedRoot && found.walkedUp,
237
303
  };
@@ -750,6 +750,83 @@ function declaredIntent(value, config) {
750
750
  );
751
751
  }
752
752
 
753
+ /** Call names whose string arguments are declared intent-reference sites. */
754
+ const INTENT_CALL_NAMES = new Set(['publish', 'subscribe', 'defineIntent', 'registerHandler']);
755
+
756
+ function isSyntaxWrapper(ts, node) {
757
+ return Boolean(
758
+ node &&
759
+ (ts.isParenthesizedExpression(node) ||
760
+ ts.isAsExpression(node) ||
761
+ (typeof ts.isTypeAssertionExpression === 'function' &&
762
+ ts.isTypeAssertionExpression(node)) ||
763
+ (typeof ts.isSatisfiesExpression === 'function' && ts.isSatisfiesExpression(node)))
764
+ );
765
+ }
766
+
767
+ function unwrapWrappers(ts, node) {
768
+ let current = node;
769
+ while (current?.parent && isSyntaxWrapper(ts, current.parent)) {
770
+ current = current.parent;
771
+ }
772
+ return current;
773
+ }
774
+
775
+ function callCalleeName(ts, node) {
776
+ if (!node || !ts.isCallExpression(node)) return undefined;
777
+ const expression = node.expression;
778
+ if (ts.isIdentifier(expression)) return expression.text;
779
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
780
+ return undefined;
781
+ }
782
+
783
+ function isPublishMetadataSource(ts, sourceProp) {
784
+ const object = sourceProp.parent;
785
+ if (!object || !ts.isObjectLiteralExpression(object)) return false;
786
+ const objectSite = unwrapWrappers(ts, object);
787
+ const objectParent = objectSite.parent;
788
+ if (!objectParent) return false;
789
+ if (ts.isCallExpression(objectParent) && callCalleeName(ts, objectParent) === 'publish') {
790
+ const args = objectParent.arguments;
791
+ return args[1] === objectSite || args[2] === objectSite;
792
+ }
793
+ if (
794
+ ts.isPropertyAssignment(objectParent) &&
795
+ syntaxPropertyName(ts, objectParent.name) === 'metadata'
796
+ ) {
797
+ const eventObject = objectParent.parent;
798
+ if (!eventObject) return false;
799
+ const eventSite = unwrapWrappers(ts, eventObject);
800
+ const call = eventSite.parent;
801
+ return Boolean(
802
+ call && ts.isCallExpression(call) && callCalleeName(ts, call) === 'publish'
803
+ );
804
+ }
805
+ return false;
806
+ }
807
+
808
+ /** Events, sagas, and publish metadata — not every string that matches a prefix. */
809
+ function isDeclaredIntentSite(ts, node) {
810
+ const siteNode = unwrapWrappers(ts, node);
811
+ const parent = siteNode.parent;
812
+ if (!parent) return false;
813
+ if (ts.isCallExpression(parent)) {
814
+ const name = callCalleeName(ts, parent);
815
+ if (INTENT_CALL_NAMES.has(name) && parent.arguments.some((arg) => arg === siteNode)) {
816
+ return true;
817
+ }
818
+ }
819
+ if (ts.isArrayLiteralExpression(parent)) {
820
+ return isDeclaredIntentSite(ts, parent);
821
+ }
822
+ if (ts.isPropertyAssignment(parent)) {
823
+ const name = syntaxPropertyName(ts, parent.name);
824
+ if (name === 'intent' || name === 'onEvent' || name === 'reactsTo') return true;
825
+ if (name === 'source' && isPublishMetadataSource(ts, parent)) return true;
826
+ }
827
+ return false;
828
+ }
829
+
753
830
  function mayContainForbiddenCapability(ts, sourceFile, forbiddenGlobals) {
754
831
  // Every symbol-aware match originates in an identifier or static string path segment.
755
832
  // Inspect decoded AST text so escaped identifiers still take the full checker path.
@@ -788,7 +865,11 @@ function collectPolicyFacts(ts, sourceFile, relativePath, config) {
788
865
  : {}),
789
866
  });
790
867
  }
791
- if (ts.isStringLiteralLike(node) && declaredIntent(node.text, config)) {
868
+ if (
869
+ ts.isStringLiteralLike(node) &&
870
+ declaredIntent(node.text, config) &&
871
+ isDeclaredIntentSite(ts, node)
872
+ ) {
792
873
  intentReferences.push({
793
874
  file: relativePath,
794
875
  line: lineOf(sourceFile, node.getStart(sourceFile)),
@@ -8,6 +8,7 @@
8
8
  * Pure CLI helper (bin/lib/rules-inventory.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
+ import { DOMAIN_EVENTS_PUSH_RE, DOMAIN_INVARIANT_WORD_RE, expectedDomainInvariantWordsPhrase, isIdiomaticEventsReset, } from './arkrules-sensors.mjs';
11
12
  function lineOf(content, index) {
12
13
  return content.slice(0, index).split('\n').length;
13
14
  }
@@ -226,7 +227,7 @@ export function buildRulesInventory(input) {
226
227
  /(?:^|\/)[^/]*(?:-access)?\.error\./i.test(posix) ||
227
228
  /(?:^|\/)errors?(?:\/|$)/i.test(posix);
228
229
  if (!isErrorBag) {
229
- const mutRe = /this\.\w+\s*=/g;
230
+ const mutRe = /\bthis\.[A-Za-z_][A-Za-z0-9_]*\s*=(?!=)/g;
230
231
  let mut;
231
232
  while ((mut = mutRe.exec(content)) !== null) {
232
233
  const classStart = content.lastIndexOf('class ', mut.index);
@@ -239,15 +240,18 @@ export function buildRulesInventory(input) {
239
240
  if (/\bextends\s+(?:Error|[A-Za-z_$][A-Za-z0-9_$]*Error)\b/.test(classHeader)) {
240
241
  continue;
241
242
  }
243
+ if (isIdiomaticEventsReset(content, mut.index))
244
+ continue;
242
245
  const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
243
- if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {
246
+ if (!DOMAIN_INVARIANT_WORD_RE.test(window) &&
247
+ !DOMAIN_EVENTS_PUSH_RE.test(window)) {
244
248
  seq += 1;
245
249
  candidates.push({
246
250
  id: `inv-mut-${seq}`,
247
251
  kind: 'mutation-without-guard',
248
252
  file,
249
253
  line: lineOf(content, mut.index),
250
- message: 'Domain field mutation without nearby guard/publish call.',
254
+ message: `Domain field mutation without nearby ${expectedDomainInvariantWordsPhrase()}.`,
251
255
  confidence: 'heuristic',
252
256
  governedLayer,
253
257
  suggestedArkRule: {