arkgate 4.8.23 → 4.8.25

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 (201) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +41 -91
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +707 -121
  6. package/bin/ark-mcp-runtime.mjs +448 -169
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +42 -22
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/adoption-stance.mjs +2 -2
  13. package/bin/lib/adr-path.mjs +3 -3
  14. package/bin/lib/agent-gates.mjs +0 -61
  15. package/bin/lib/agent-homes.mjs +12 -21
  16. package/bin/lib/agent-projection-command.mjs +9 -5
  17. package/bin/lib/ambient-state.mjs +9 -30
  18. package/bin/lib/analysis-engine.mjs +15 -11
  19. package/bin/lib/architecture-scan.mjs +61 -15
  20. package/bin/lib/ark-order-doctor.mjs +4 -29
  21. package/bin/lib/ark-order-facts.mjs +319 -17
  22. package/bin/lib/ark-order-sensors.mjs +28 -101
  23. package/bin/lib/ark-order-types.mjs +3 -2
  24. package/bin/lib/ark-run-doctor.mjs +17 -28
  25. package/bin/lib/ark-run-facts.mjs +461 -48
  26. package/bin/lib/ark-run-sensors.mjs +6 -4
  27. package/bin/lib/arkgate-pins.mjs +91 -0
  28. package/bin/lib/arkrule-file-hints.mjs +2 -47
  29. package/bin/lib/arkrules-contract.mjs +124 -133
  30. package/bin/lib/arkrules-sensors.mjs +55 -252
  31. package/bin/lib/check-args.mjs +85 -1
  32. package/bin/lib/ci-and-commands.mjs +24 -78
  33. package/bin/lib/ci-merge-boundary.mjs +1 -1
  34. package/bin/lib/class-source-scan.mjs +479 -0
  35. package/bin/lib/clone-detection.mjs +474 -0
  36. package/bin/lib/codex-home.mjs +5 -3
  37. package/bin/lib/config-contract-slices.mjs +739 -0
  38. package/bin/lib/config-contract.mjs +42 -607
  39. package/bin/lib/config-extras.mjs +16 -22
  40. package/bin/lib/config-warnings.mjs +32 -0
  41. package/bin/lib/contract-smells.mjs +33 -15
  42. package/bin/lib/core-ratchet.mjs +0 -2
  43. package/bin/lib/dashboard-cli.mjs +62 -0
  44. package/bin/lib/deep-module-coach.mjs +2 -4
  45. package/bin/lib/deploy-path.mjs +13 -38
  46. package/bin/lib/design-delta.mjs +1 -1
  47. package/bin/lib/design-smells.mjs +2 -2
  48. package/bin/lib/diagnostic-catalog.mjs +23 -9
  49. package/bin/lib/doctor-advisories.mjs +78 -8
  50. package/bin/lib/doctor-green-cite.mjs +1 -1
  51. package/bin/lib/doctor-human.mjs +1 -1
  52. package/bin/lib/doctor-next-actions.mjs +19 -4
  53. package/bin/lib/doctor-plan.mjs +9 -13
  54. package/bin/lib/duplication-io.mjs +572 -0
  55. package/bin/lib/effective-contract-load.mjs +282 -83
  56. package/bin/lib/enforcement-profiles.mjs +40 -4
  57. package/bin/lib/entry-points-io.mjs +424 -0
  58. package/bin/lib/extra-merge-teeth.mjs +36 -0
  59. package/bin/lib/field-install.mjs +38 -14
  60. package/bin/lib/first-run-help.mjs +10 -5
  61. package/bin/lib/flat-parent-importers.mjs +139 -0
  62. package/bin/lib/flat-parent-pilot.mjs +40 -100
  63. package/bin/lib/gate-files.mjs +58 -90
  64. package/bin/lib/git-change-scope.mjs +109 -0
  65. package/bin/lib/github-enforcement.mjs +166 -29
  66. package/bin/lib/golden-pattern.mjs +1 -1
  67. package/bin/lib/hook-templates.mjs +17 -15
  68. package/bin/lib/host-support-matrix.mjs +3 -1
  69. package/bin/lib/html-report-advisories.mjs +4 -0
  70. package/bin/lib/html-report-depth.mjs +2 -2
  71. package/bin/lib/html-report.mjs +2 -2
  72. package/bin/lib/import-graph-projection.mjs +112 -0
  73. package/bin/lib/import-resolve.mjs +13 -1
  74. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  75. package/bin/lib/install-migrate.mjs +50 -73
  76. package/bin/lib/invariant-coverage-io.mjs +4 -3
  77. package/bin/lib/invariant-coverage.mjs +85 -11
  78. package/bin/lib/invariant-probe-cli.mjs +347 -0
  79. package/bin/lib/invariant-probe-io.mjs +165 -0
  80. package/bin/lib/invariant-probe-runner.mjs +268 -0
  81. package/bin/lib/invariant-probe-sites.mjs +174 -0
  82. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  83. package/bin/lib/invariant-probe.mjs +461 -0
  84. package/bin/lib/invariant-tests-path.mjs +16 -22
  85. package/bin/lib/literal-path-drift.mjs +4 -2
  86. package/bin/lib/managed-upgrade.mjs +2 -8
  87. package/bin/lib/mcp-adoption.mjs +13 -6
  88. package/bin/lib/mcp-hook-payload.mjs +403 -63
  89. package/bin/lib/orphan-modules-io.mjs +458 -0
  90. package/bin/lib/orphan-modules.mjs +251 -0
  91. package/bin/lib/outside-importers.mjs +197 -0
  92. package/bin/lib/package-manager.mjs +60 -2
  93. package/bin/lib/parse-health.mjs +1 -1
  94. package/bin/lib/passthrough-commands.mjs +83 -0
  95. package/bin/lib/persistence-write-hint.mjs +106 -0
  96. package/bin/lib/pin-dependent-gates.mjs +117 -0
  97. package/bin/lib/policy-delta-io.mjs +219 -14
  98. package/bin/lib/post-green-path.mjs +1 -1
  99. package/bin/lib/prepare-change.mjs +115 -7
  100. package/bin/lib/prepare-write.mjs +2 -2
  101. package/bin/lib/presets.mjs +59 -73
  102. package/bin/lib/product-copy.mjs +1 -6
  103. package/bin/lib/project-root.mjs +31 -62
  104. package/bin/lib/remediation.mjs +23 -4
  105. package/bin/lib/resolved-candidate-facts.mjs +405 -117
  106. package/bin/lib/rules-inventory.mjs +22 -3
  107. package/bin/lib/rules-under-contract.mjs +247 -9
  108. package/bin/lib/scan-files.mjs +36 -4
  109. package/bin/lib/schema-validation.mjs +130 -0
  110. package/bin/lib/sensor-promote-cli.mjs +2 -11
  111. package/bin/lib/sensor-promote-io.mjs +33 -22
  112. package/bin/lib/sensor-promotion.mjs +1 -1
  113. package/bin/lib/skill-catalog-apply.mjs +1 -1
  114. package/bin/lib/skill-install.mjs +42 -38
  115. package/bin/lib/snippet-analysis.mjs +140 -6
  116. package/bin/lib/source-policy.mjs +34 -0
  117. package/bin/lib/start-install-recovery.mjs +72 -2
  118. package/bin/lib/start-preview.mjs +31 -1
  119. package/bin/lib/states-transitions-presence.mjs +3 -3
  120. package/bin/lib/status-command.mjs +35 -22
  121. package/bin/lib/status-manifest.mjs +50 -30
  122. package/bin/lib/status-transition-catalog.mjs +0 -6
  123. package/bin/lib/suggestions.mjs +3 -3
  124. package/bin/lib/team-parliament-io.mjs +76 -88
  125. package/bin/lib/team-parliament.mjs +21 -10
  126. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  127. package/bin/lib/upgrade-command.mjs +2 -24
  128. package/bin/lib/upgrade-outcome.mjs +1 -1
  129. package/bin/lib/upstream-report.mjs +21 -10
  130. package/bin/lib/violations.mjs +10 -9
  131. package/bin/lib/weakest-link.mjs +13 -0
  132. package/bin/lib/write-path-capabilities.mjs +6 -4
  133. package/dist/chunk-A5EIY3HI.cjs +5 -0
  134. package/dist/chunk-AHACEKSV.js +16 -0
  135. package/dist/chunk-SVSQVGFJ.js +5 -0
  136. package/dist/chunk-UN5EH4QV.cjs +16 -0
  137. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
  138. package/dist/configTypes-GPvvdleM.d.ts +257 -0
  139. package/dist/eslint/index.cjs +18 -5
  140. package/dist/eslint/index.d.cts +368 -0
  141. package/dist/eslint/index.d.ts +139 -156
  142. package/dist/eslint/index.js +11 -5
  143. package/dist/index.cjs +36 -35
  144. package/dist/index.d.cts +5547 -0
  145. package/dist/index.d.ts +3283 -198
  146. package/dist/index.js +36 -35
  147. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  148. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  149. package/dist/nestjs/index.cjs +1 -18
  150. package/dist/nestjs/index.d.cts +22 -0
  151. package/dist/nestjs/index.d.ts +1 -3
  152. package/dist/nestjs/index.js +1 -18
  153. package/dist/order/index.cjs +1 -1
  154. package/dist/order/index.d.cts +155 -0
  155. package/dist/order/index.d.ts +15 -3
  156. package/dist/order/index.js +1 -1
  157. package/dist/runtime/index.cjs +13 -30
  158. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
  159. package/dist/runtime/index.d.ts +2741 -133
  160. package/dist/runtime/index.js +13 -30
  161. package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
  162. package/dist/types-CnauMQQL.d.ts +1800 -0
  163. package/docs/README.md +3 -1
  164. package/docs/agent-guide.md +217 -16
  165. package/docs/ai-gates.md +174 -26
  166. package/docs/arkorder.md +32 -15
  167. package/docs/brownfield-adoption.md +5 -1
  168. package/docs/configuration.md +230 -31
  169. package/docs/develop.md +2 -1
  170. package/docs/diagnostics.md +212 -19
  171. package/docs/package-surface.md +35 -9
  172. package/docs/product-voice.md +7 -1
  173. package/package.json +69 -19
  174. package/schemas/ark.analysis-result.schema.json +15 -0
  175. package/schemas/ark.config.schema.json +77 -12
  176. package/schemas/ark.invariant-probe.schema.json +368 -0
  177. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  178. package/schemas/ark.status-manifest.schema.json +22 -0
  179. package/server.json +3 -9
  180. package/templates/agent-skills/README.md +1 -1
  181. package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
  182. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  183. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  184. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  185. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  186. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  187. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  188. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  189. package/templates/arkrules/PersistenceAdapters.json +1 -1
  190. package/templates/arkrules/PresentationAdapters.json +1 -1
  191. package/templates/skills/ark-adopt.md +5 -1
  192. package/templates/skills/ark-contract.md +17 -0
  193. package/templates/skills/ark-coverage.md +2 -0
  194. package/templates/skills/ark-explore.md +7 -0
  195. package/templates/skills/ark-fix.md +20 -0
  196. package/templates/skills/ark-order.md +4 -2
  197. package/templates/skills/ark-place.md +8 -0
  198. package/templates/skills/ark-runtime.md +6 -1
  199. package/bin/lib/ark-order-error.mjs +0 -18
  200. package/bin/lib/ark-order-invariants.mjs +0 -346
  201. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -8,6 +8,8 @@
8
8
  * Pure CLI helper (bin/lib/arkrules-sensors.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
+ import { findClassDeclarations, scanClassMembers } from './class-source-scan.mjs';
12
+ import { sourceHasPersistenceWrite, sourceHasTaggedSqlWrite, sourceImportsPersistenceDriverText, } from './persistence-write-hint.mjs';
11
13
  /** Keep in lockstep with arkRulesTypes.ARK_RULE_TIER2_SENSOR_IDS (self-contained for CLI gen). */
12
14
  const ARK_RULE_TIER2_SENSOR_IDS = ['no-anemic-model'];
13
15
  /**
@@ -30,11 +32,10 @@ export const DOMAIN_EVENTS_PUSH_RE = new RegExp(`\\bthis\\.${EVENTS_ARRAY_PROP}\
30
32
  const EVENTS_ARRAY_RESET_RE = new RegExp(`^this\\.${EVENTS_ARRAY_PROP}\\s*=\\s*\\[\\s*\\]`);
31
33
  const ANY_THIS_EMPTY_ARRAY_RE = /^this\.[A-Za-z_][A-Za-z0-9_]*\s*=\s*\[\s*\]/;
32
34
  const THIS_FIELD_ASSIGNMENT_RE = /\bthis\.[A-Za-z_][A-Za-z0-9_]*\s*=(?!=)/g;
33
- const SHAPE_TRUNCATED_UNTIL = 'truncatedUntil';
34
35
  export function expectedDomainInvariantWordsPhrase() {
35
36
  return `${DOMAIN_INVARIANT_WORDS.join(', ')}, or events-array .push(`;
36
37
  }
37
- export function referencesGuardOrPublish(source) {
38
+ function referencesGuardOrPublish(source) {
38
39
  return DOMAIN_INVARIANT_WORD_RE.test(source) || DOMAIN_EVENTS_PUSH_RE.test(source);
39
40
  }
40
41
  export function isIdiomaticEventsReset(source, assignIndex, methodName) {
@@ -59,18 +60,10 @@ function methodAssignsThis(methodName, methodBody) {
59
60
  return false;
60
61
  }
61
62
  function attachShapeTruncation(shape, truncatedUntil) {
62
- if (truncatedUntil == null)
63
- return shape;
64
- Object.defineProperty(shape, SHAPE_TRUNCATED_UNTIL, {
65
- value: truncatedUntil,
66
- enumerable: false,
67
- configurable: true,
68
- });
69
- return shape;
63
+ return truncatedUntil == null ? shape : { ...shape, truncatedUntil };
70
64
  }
71
65
  function shapeTruncatedUntil(shape) {
72
- const value = Object.getOwnPropertyDescriptor(shape, SHAPE_TRUNCATED_UNTIL)?.value;
73
- return typeof value === 'number' ? value : undefined;
66
+ return typeof shape.truncatedUntil === 'number' ? shape.truncatedUntil : undefined;
74
67
  }
75
68
  function shapeTruncationSuffix(shape) {
76
69
  const until = shapeTruncatedUntil(shape);
@@ -188,9 +181,22 @@ function shapesForRule(rule, shapes, layerForFile) {
188
181
  return true;
189
182
  });
190
183
  }
184
+ /**
185
+ * A shape the scanner could not walk to its end (unbalanced header/body) is
186
+ * reported, never passed in silence: later members may be invisible.
187
+ */
188
+ function truncatedShapeViolation(rule, shape, what) {
189
+ const until = shapeTruncatedUntil(shape);
190
+ if (until == null)
191
+ return undefined;
192
+ return baseViolation(rule, shape.file, `Exported class ${shape.className} shape analysed until character ${until}; later ${what} may be invisible (sensor ${rule.sensor}).`);
193
+ }
191
194
  function evaluateAggregatePrivateState(rule, shapes, layerForFile) {
192
195
  const out = [];
193
196
  for (const shape of shapesForRule(rule, shapes, layerForFile)) {
197
+ const truncated = truncatedShapeViolation(rule, shape, 'members');
198
+ if (truncated)
199
+ out.push(truncated);
194
200
  // P1-L — require real mutability (setters or non-readonly public fields).
195
201
  // Readonly public props on intentional value/entity shapes are false-positive-prone.
196
202
  if (shape.hasPublicSetters || shape.hasPublicMutableFields) {
@@ -202,6 +208,9 @@ function evaluateAggregatePrivateState(rule, shapes, layerForFile) {
202
208
  function evaluateAlwaysValidFactory(rule, shapes, layerForFile) {
203
209
  const out = [];
204
210
  for (const shape of shapesForRule(rule, shapes, layerForFile)) {
211
+ const truncated = truncatedShapeViolation(rule, shape, 'constructors and factories');
212
+ if (truncated)
213
+ out.push(truncated);
205
214
  if (shape.hasPublicConstructor && !shape.hasStaticFactory) {
206
215
  // P1-L — prefer false negatives on intentional DDD / DI aggregates: a public
207
216
  // constructor alone is weak evidence. Only fire when mutable public surface
@@ -221,9 +230,9 @@ function evaluateDomainEventOnMutation(rule, shapes, layerForFile) {
221
230
  const expected = expectedDomainInvariantWordsPhrase();
222
231
  for (const shape of shapesForRule(rule, shapes, layerForFile)) {
223
232
  const truncation = shapeTruncationSuffix(shape);
224
- if (shapeTruncatedUntil(shape) != null) {
225
- out.push(baseViolation(rule, shape.file, `Exported class ${shape.className} shape analysed until character ${shapeTruncatedUntil(shape)}; later methods may be invisible (sensor domain-event-on-mutation).`));
226
- }
233
+ const truncated = truncatedShapeViolation(rule, shape, 'methods');
234
+ if (truncated)
235
+ out.push(truncated);
227
236
  for (const method of shape.mutatingMethods) {
228
237
  if (!method.referencesGuardOrPublish) {
229
238
  out.push(baseViolation(rule, shape.file, `Mutating method ${shape.className}.${method.name} does not reference ${expected} (sensor domain-event-on-mutation).${truncation}`));
@@ -459,24 +468,11 @@ export function collectEmptyInvariantCatalogFindings(input) {
459
468
  },
460
469
  ];
461
470
  }
462
- /** IO / ORM import evidence. postgres and drizzle-orm include package subpaths. Keep in lockstep with arkOrderFacts. */
463
- const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm(?:\/[^'"]+)?|postgres(?:\/[^'"]+)?|typeorm|knex|mongodb|pg|mysql2|mongoose|better-sqlite3|ioredis|redis|kysely|sequelize)['"]|require\(\s*['"](?:@?prisma\/client|pg|postgres(?:\/[^'"]+)?|drizzle-orm(?:\/[^'"]+)?|knex|typeorm|mongoose)/;
464
- /**
465
- * Path-alias / local db module (`@/lib/db`) without resolving tsconfig.
466
- * Keep in lockstep with arkOrderFacts.
467
- */
468
- const IO_ALIAS_IMPORT_RE = /\bfrom\s+['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle)(?:\.[cm]?[jt]sx?)?['"]|require\(\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle)/;
469
- /**
470
- * Write tokens that skip the aggregate when paired with a persistence driver import.
471
- * Callee must be db|tx|client|prisma|drizzle (PrismaClient included); not repo.update(.
472
- * Keep in lockstep with arkOrderFacts.
473
- */
474
- const PERSISTENCE_WRITE_HINT_RE = /\b(?:db|tx|client|prisma(?:Client)?|drizzle)\b(?:\s*\.\s*[A-Za-z_]\w*)*\s*\.\s*(?:insert(?:One|Many)?|update(?:One|Many)?|upsert|delete(?:One|Many)?|createMany|create|replaceOne|findOneAnd(?:Update|Delete|Replace))\s*\(|\bINSERT\s+INTO\b|\bUPDATE\s+[A-Za-z_][\w.]*\s+SET\b|\bDELETE\s+FROM\b/i;
475
- export function isPersistenceDriverLayer(layer) {
471
+ function isPersistenceDriverLayer(layer) {
476
472
  return layer === 'PersistenceAdapters';
477
473
  }
478
- export function sourceImportsPersistenceDriver(content, resolvedImports) {
479
- if (IO_IMPORT_HINT_RE.test(content) || IO_ALIAS_IMPORT_RE.test(content))
474
+ function sourceImportsPersistenceDriver(content, resolvedImports) {
475
+ if (sourceImportsPersistenceDriverText(content))
480
476
  return true;
481
477
  if (!resolvedImports)
482
478
  return false;
@@ -487,7 +483,7 @@ export function sourceImportsPersistenceDriver(content, resolvedImports) {
487
483
  if (!specifier)
488
484
  continue;
489
485
  const synthetic = `from '${specifier}'`;
490
- if (IO_IMPORT_HINT_RE.test(synthetic) || IO_ALIAS_IMPORT_RE.test(synthetic))
486
+ if (sourceImportsPersistenceDriverText(synthetic))
491
487
  return true;
492
488
  }
493
489
  return false;
@@ -506,7 +502,9 @@ export function deriveArkRuleFileHints(_file, content, resolvedImports) {
506
502
  if (!content)
507
503
  return null;
508
504
  const hasIo = sourceImportsPersistenceDriver(content, resolvedImports);
509
- const persistenceWrite = hasIo && PERSISTENCE_WRITE_HINT_RE.test(content);
505
+ // A tagged-template SQL write head is evidence on its own (the use case composed
506
+ // the statement). ORM verbs and untagged SQL still need a driver import.
507
+ const persistenceWrite = (hasIo && sourceHasPersistenceWrite(content)) || sourceHasTaggedSqlWrite(content);
510
508
  // Orchestration/adapter heuristics need a longer window; writes still fire on short probes.
511
509
  if (content.length < 40) {
512
510
  return persistenceWrite ? { persistenceWrite: true } : null;
@@ -549,213 +547,18 @@ export function buildArkRuleFileHints(fileContents, resolvedImportsByFile) {
549
547
  }
550
548
  return out;
551
549
  }
552
- const MEMBER_MODIFIERS = new Set([
553
- 'public',
554
- 'private',
555
- 'protected',
556
- 'static',
557
- 'async',
558
- 'readonly',
559
- 'abstract',
560
- 'override',
561
- 'declare',
562
- 'get',
563
- 'set',
564
- ]);
565
550
  const CONTROL_FLOW_METHOD_NAMES = new Set(['if', 'match', 'when']);
566
- function skipStringOrComment(src, index) {
567
- const ch = src[index];
568
- if (ch === '/' && src[index + 1] === '/') {
569
- const nl = src.indexOf('\n', index);
570
- return nl === -1 ? src.length : nl;
571
- }
572
- if (ch === '/' && src[index + 1] === '*') {
573
- const end = src.indexOf('*/', index + 2);
574
- return end === -1 ? src.length : end + 2;
575
- }
576
- if (ch === "'" || ch === '"' || ch === '`') {
577
- let j = index + 1;
578
- while (j < src.length) {
579
- if (src[j] === '\\') {
580
- j += 2;
581
- continue;
582
- }
583
- if (src[j] === ch)
584
- return j + 1;
585
- j += 1;
586
- }
587
- return src.length;
588
- }
589
- return index;
590
- }
591
- function skipWsAndComments(src, index) {
592
- let i = index;
593
- while (i < src.length) {
594
- if (/\s/.test(src[i])) {
595
- i += 1;
596
- continue;
597
- }
598
- if (src[i] === '/' && (src[i + 1] === '/' || src[i + 1] === '*')) {
599
- i = skipStringOrComment(src, i);
600
- continue;
601
- }
602
- break;
603
- }
604
- return i;
605
- }
606
- function readIdent(src, index) {
607
- const ch = src[index];
608
- if (!ch || !/[A-Za-z_]/.test(ch))
609
- return null;
610
- let j = index + 1;
611
- while (j < src.length && /[A-Za-z0-9_]/.test(src[j]))
612
- j += 1;
613
- return { ident: src.slice(index, j), end: j };
614
- }
615
- function skipBalanced(src, openIndex, openCh, closeCh) {
616
- if (src[openIndex] !== openCh)
617
- return null;
618
- let depth = 1;
619
- let i = openIndex + 1;
620
- while (i < src.length && depth > 0) {
621
- const skipped = skipStringOrComment(src, i);
622
- if (skipped !== i) {
623
- i = skipped;
624
- continue;
625
- }
626
- const ch = src[i];
627
- if (ch === openCh)
628
- depth += 1;
629
- else if (ch === closeCh)
630
- depth -= 1;
631
- i += 1;
632
- }
633
- return depth === 0 ? i : null;
634
- }
635
- function scanClassMembers(body) {
636
- const members = [];
637
- let i = 0;
638
- let truncatedAt;
639
- while (i < body.length) {
640
- i = skipWsAndComments(body, i);
641
- if (i >= body.length)
642
- break;
643
- if (body[i] === ';') {
644
- i += 1;
645
- continue;
646
- }
647
- const modifiers = [];
648
- let cursor = i;
649
- while (true) {
650
- const tok = readIdent(body, cursor);
651
- if (!tok || !MEMBER_MODIFIERS.has(tok.ident))
652
- break;
653
- modifiers.push(tok.ident);
654
- cursor = skipWsAndComments(body, tok.end);
655
- }
656
- const nameTok = readIdent(body, cursor);
657
- if (!nameTok) {
658
- i += 1;
659
- continue;
660
- }
661
- cursor = skipWsAndComments(body, nameTok.end);
662
- if (body[cursor] === '<') {
663
- const afterGeneric = skipBalanced(body, cursor, '<', '>');
664
- if (afterGeneric == null) {
665
- truncatedAt = body.length;
666
- break;
667
- }
668
- cursor = skipWsAndComments(body, afterGeneric);
669
- }
670
- if (body[cursor] === '(') {
671
- const afterParen = skipBalanced(body, cursor, '(', ')');
672
- if (afterParen == null) {
673
- truncatedAt = body.length;
674
- break;
675
- }
676
- cursor = skipWsAndComments(body, afterParen);
677
- if (body[cursor] === ':') {
678
- cursor += 1;
679
- while (cursor < body.length && body[cursor] !== '{' && body[cursor] !== ';') {
680
- const skipped = skipStringOrComment(body, cursor);
681
- if (skipped !== cursor) {
682
- cursor = skipped;
683
- continue;
684
- }
685
- cursor += 1;
686
- }
687
- }
688
- if (body[cursor] === '{') {
689
- const afterBrace = skipBalanced(body, cursor, '{', '}');
690
- if (afterBrace == null) {
691
- truncatedAt = body.length;
692
- break;
693
- }
694
- members.push({
695
- name: nameTok.ident,
696
- modifiers,
697
- kind: 'method',
698
- body: body.slice(cursor + 1, afterBrace - 1),
699
- });
700
- i = afterBrace;
701
- continue;
702
- }
703
- if (body[cursor] === ';') {
704
- i = cursor + 1;
705
- continue;
706
- }
707
- i = cursor + 1;
708
- continue;
709
- }
710
- let depthBrace = 0;
711
- let depthParen = 0;
712
- let depthBracket = 0;
713
- while (cursor < body.length) {
714
- const skipped = skipStringOrComment(body, cursor);
715
- if (skipped !== cursor) {
716
- cursor = skipped;
717
- continue;
718
- }
719
- const ch = body[cursor];
720
- if (ch === '{')
721
- depthBrace += 1;
722
- else if (ch === '}') {
723
- if (depthBrace === 0)
724
- break;
725
- depthBrace -= 1;
726
- }
727
- else if (ch === '(')
728
- depthParen += 1;
729
- else if (ch === ')')
730
- depthParen -= 1;
731
- else if (ch === '[')
732
- depthBracket += 1;
733
- else if (ch === ']')
734
- depthBracket -= 1;
735
- else if (ch === ';' &&
736
- depthBrace === 0 &&
737
- depthParen === 0 &&
738
- depthBracket === 0) {
739
- cursor += 1;
740
- break;
741
- }
742
- cursor += 1;
743
- }
744
- members.push({
745
- name: nameTok.ident,
746
- modifiers,
747
- kind: 'field',
748
- body: '',
749
- });
750
- i = cursor;
751
- }
752
- return { members, truncatedAt };
753
- }
754
551
  /**
755
552
  * Lightweight class-shape extraction from TypeScript source text (no compiler).
756
553
  * Conservative: prefers false negatives over false positives for mutability.
757
554
  * Tooling may replace with TypeScript-API facts; sensors consume the same shape.
758
555
  *
556
+ * The header and body are walked by the shared tokenizer (classSourceScan):
557
+ * type parameters (`class Box<T>`), generic `extends` / `implements` clauses, and
558
+ * braces inside strings, comments, or template literals are handled. A class whose
559
+ * header or body cannot be walked is still reported, with a truncation mark, so
560
+ * enforced sensors say so instead of passing in silence.
561
+ *
759
562
  * Limitation (AR05/AR06): only `export class` / `export abstract class` forms.
760
563
  * `export default class`, re-exported classes, and non-exported aggregates are
761
564
  * invisible — enforced structure sensors stay silent (false negative). Silence
@@ -763,25 +566,25 @@ function scanClassMembers(body) {
763
566
  */
764
567
  export function extractClassShapesFromSource(file, content) {
765
568
  const shapes = [];
766
- // Match exported class declarations (simple cases; see limitation above).
767
- const classRe = /export\s+(?:abstract\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:extends\s+[^{]+)?(?:implements\s+[^{]+)?\{/g;
768
- let match;
769
- while ((match = classRe.exec(content)) !== null) {
770
- const className = match[1];
771
- const start = match.index + match[0].length;
772
- // Brace match body
773
- let depth = 1;
774
- let i = start;
775
- while (i < content.length && depth > 0) {
776
- const ch = content[i];
777
- if (ch === '{')
778
- depth += 1;
779
- else if (ch === '}')
780
- depth -= 1;
781
- i += 1;
569
+ for (const decl of findClassDeclarations(content, { exportedOnly: true })) {
570
+ const className = decl.name;
571
+ if (decl.bodyStart == null) {
572
+ // Header seen, body not derivable: an unshaped class, never a silent pass.
573
+ shapes.push(attachShapeTruncation({
574
+ file,
575
+ className,
576
+ exported: true,
577
+ hasPublicMutableFields: false,
578
+ hasPublicSetters: false,
579
+ hasPublicConstructor: false,
580
+ hasStaticFactory: false,
581
+ mutatingMethods: [],
582
+ }, decl.start));
583
+ continue;
782
584
  }
783
- const body = content.slice(start, i - 1);
784
- const classUnclosed = depth > 0;
585
+ const start = decl.bodyStart;
586
+ const classUnclosed = decl.bodyEnd == null;
587
+ const body = content.slice(start, decl.bodyEnd ?? content.length);
785
588
  const scanned = scanClassMembers(body);
786
589
  const truncatedUntil = classUnclosed
787
590
  ? content.length
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * ark-check CLI flag parsing.
3
3
  */
4
+ import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import { discoverLocalBaseRef, normalizePolicyBaseRef } from './policy-delta-io.mjs';
6
7
 
@@ -23,7 +24,26 @@ export const LOCAL_STRICT_MERGE_MESSAGE =
23
24
  '--local cannot be combined with --strict-merge: CI stays the full fail-closed check. Use --local (or ARK_CHECK_LOCAL=1) only on the local / pre-push path.';
24
25
 
25
26
  export const LOCAL_TREE_MODE_MESSAGE =
26
- '--local cannot be combined with report modes that need the whole tree (--doctor, --coverage, --plan, --report, --promote). Use --local --base <ref> for the cheap local check.';
27
+ '--local cannot be combined with report modes that need the whole tree (--doctor, --coverage, --plan, --report, --promote, --probe-invariants) or with --update-baseline. Use --local --base <ref> for the cheap local check.';
28
+
29
+ const PROBE_RUNNERS = ['vitest', 'jest', 'node'];
30
+
31
+ export const UPDATE_BASELINE_SCOPE_MESSAGE =
32
+ '--update-baseline cannot be combined with a changed-files scope (--changed, --local, or a --persona that implies --changed): the baseline is a whole-tree record, and a changed-files scan would overwrite it with only that subset and delete every other frozen key. Drop the scope flag (ARK_CHECK_LOCAL=1 is ignored for a baseline update) and re-run.';
33
+
34
+ /**
35
+ * Baseline file that suppresses frozen findings for this run, or null.
36
+ * Explicit `--baseline` always wins. `--report` (without `--against`) applies the committed
37
+ * `.ark-baseline.json` by default, the same file `--doctor` reads, so report, `ark status`
38
+ * and doctor agree on what is active vs frozen.
39
+ */
40
+ export function effectiveBaselineName(args, root, exists = fs.existsSync) {
41
+ if (args?.baseline) return args.baseline;
42
+ // A merge verdict (--strict / --strict-merge / --contract-diff) never inherits the
43
+ // committed freeze implicitly: adding --report must not flip a fail-closed run to pass.
44
+ if (!args?.report || args.against || args.strictMerge || args.contractDiff) return null;
45
+ return exists(path.join(root, '.ark-baseline.json')) ? '.ark-baseline.json' : null;
46
+ }
27
47
 
28
48
  export function localCheckEnvelope(args, root) {
29
49
  if (!args?.local) return {};
@@ -48,6 +68,8 @@ export function applyLocalCheckMode(args, env = process.env) {
48
68
  args.plan && '--plan',
49
69
  args.report && '--report',
50
70
  args.promote && '--promote',
71
+ args.probeInvariants && '--probe-invariants',
72
+ args.updateBaseline && '--update-baseline',
51
73
  ].filter(Boolean);
52
74
  if (args.strictMerge) {
53
75
  if (explicit) throw new Error(LOCAL_STRICT_MERGE_MESSAGE);
@@ -182,6 +204,24 @@ export function parseArgs(argv, env = process.env) {
182
204
  args.promote = next && !next.startsWith('-') ? argv[++i] : true;
183
205
  }
184
206
  }
207
+ else if (arg === '--probe-invariants' || arg.startsWith('--probe-invariants=')) {
208
+ // Same shape as --promote: bare probes every declared invariant; a value
209
+ // narrows to one id (`=<id>` also carries an id that begins with '-').
210
+ const eq = arg.indexOf('=');
211
+ if (eq > 0) {
212
+ args.probeInvariants = arg.slice(eq + 1) || true;
213
+ } else {
214
+ const next = argv[i + 1];
215
+ args.probeInvariants = next && !next.startsWith('-') ? argv[++i] : true;
216
+ }
217
+ }
218
+ else if (arg === '--runner') {
219
+ const value = requireValue(arg, i++).trim().toLowerCase();
220
+ if (!PROBE_RUNNERS.includes(value)) {
221
+ throw new Error(`--runner must be one of ${PROBE_RUNNERS.join(', ')} (got ${JSON.stringify(value)}).`);
222
+ }
223
+ args.runner = value;
224
+ }
185
225
  else if (arg === '--apply') args.apply = true;
186
226
  else if (arg === '--recommend') args.recommend = true;
187
227
  else if (arg === '--write-plan') args.writePlan = true;
@@ -288,5 +328,49 @@ export function parseArgs(argv, env = process.env) {
288
328
  );
289
329
  }
290
330
  }
331
+ assertProbeInvariantsAlone(args);
332
+ if (args.updateBaseline && (args.changed || args.local)) {
333
+ throw new Error(UPDATE_BASELINE_SCOPE_MESSAGE);
334
+ }
291
335
  return applyLocalCheckMode(args, env);
292
336
  }
337
+
338
+ /**
339
+ * ADR 0039 D1: the probe runs the project's own tests in a temporary copy, so
340
+ * it is never part of a check, the merge gate, status, or a changed-files scope.
341
+ * Every pairing that would put it on one of those paths is refused, and a
342
+ * report mode that answers first would make it silently do nothing.
343
+ */
344
+ function assertProbeInvariantsAlone(args) {
345
+ if (args.runner && !args.probeInvariants) {
346
+ throw new Error('--runner applies to --probe-invariants. Run `arkgate-check --probe-invariants --runner <id>`.');
347
+ }
348
+ if (!args.probeInvariants) return;
349
+ const gatePaths = [
350
+ args.local && '--local',
351
+ args.changed && '--changed',
352
+ args.strictMerge && '--strict-merge',
353
+ args.updateBaseline && '--update-baseline',
354
+ args.doctor && '--doctor',
355
+ ].filter(Boolean);
356
+ if (gatePaths.length > 0) {
357
+ throw new Error(
358
+ `--probe-invariants cannot be combined with ${gatePaths.join(', ')}: the probe runs your tests in a temporary copy and is never part of a check, the merge gate, or status. Run it on its own.`
359
+ );
360
+ }
361
+ const shadowing = [
362
+ args.promote && '--promote',
363
+ args.sensors && '--sensors',
364
+ args.pathDrift && '--path-drift',
365
+ args.coverage && '--coverage',
366
+ args.plan && '--plan',
367
+ args.report && '--report',
368
+ args.rulesInventory && '--rules-inventory',
369
+ args.recommend && '--recommend',
370
+ ].filter(Boolean);
371
+ if (shadowing.length > 0) {
372
+ throw new Error(
373
+ `--probe-invariants cannot be combined with ${shadowing.join(', ')}: one of them would answer first and the other would silently do nothing. Run them separately.`
374
+ );
375
+ }
376
+ }
@@ -16,22 +16,12 @@ import {
16
16
  buildAgentProjectionBlock,
17
17
  } from './agent-projection.mjs';
18
18
  import { getDiagnosticCatalogEntry } from './diagnostic-catalog.mjs';
19
- import { falseGreenAdoptionGap } from './field-install.mjs';
19
+ import { addPackageJsonPropertyPreservingFormat } from './field-install.mjs';
20
20
  import { renderHostSupportMatrixMarkdown } from './host-support-matrix.mjs';
21
21
  import { PREFERRED_MCP_BIN } from './hook-templates.mjs';
22
22
  import { hasCheckArchitectureScript, readPackageJson } from './gate-files.mjs';
23
23
  import { arkPackageVersion } from './skill-install.mjs';
24
-
25
- // Field-install helpers re-exported for callers that import from this module.
26
- export {
27
- ensureBaselineFlagInCheckCommand,
28
- syncBaselineIntoCheckSurfaces,
29
- pinArkgateDevDependency,
30
- IO_DIR_SEGMENTS,
31
- detectContractFalseGreenRisk,
32
- FALSE_GREEN_GAP_ID,
33
- falseGreenAdoptionGap,
34
- } from './field-install.mjs';
24
+ import { arkgateIsProjectDependency, pinnedArkgateRunner } from './package-manager.mjs';
35
25
 
36
26
  export function checkArgsForRoot(root, { requireGates = false } = {}) {
37
27
  const baselineFlag = fs.existsSync(path.join(root, '.ark-baseline.json'))
@@ -41,8 +31,18 @@ export function checkArgsForRoot(root, { requireGates = false } = {}) {
41
31
  return `--root . --config ark.config.json ${profile}${baselineFlag}`;
42
32
  }
43
33
 
44
-
45
34
  export function packageManager(root) {
35
+ const pm = localPackageManager(root);
36
+ if (arkgateIsProjectDependency(root)) return pm;
37
+ // arkgate is not pinned: the local runner would ask npm for a package named `ark-check`
38
+ // (404) or run a stale global, so name the exact arkgate that generated this workflow.
39
+ const run = `${pinnedArkgateRunner()} ark-check ${checkArgsForRoot(root, { requireGates: true })}`;
40
+ // No package.json: nothing to install, and setup-node's cache needs a lockfile.
41
+ if (!readPackageJson(root)) return { cache: null, setup: [], install: null, run };
42
+ return { ...pm, run };
43
+ }
44
+
45
+ function localPackageManager(root) {
46
46
  // CI always require-gates; baseline follows checkArgsForRoot.
47
47
  const checkArgs = checkArgsForRoot(root, { requireGates: true });
48
48
  // Same detection as every emitted command (execRunner): honors the packageManager field and
@@ -102,65 +102,13 @@ export function checkArchitectureScriptSnippet(root) {
102
102
  * @param {string} scriptValue
103
103
  */
104
104
  function addPackageScriptPreservingFormat(source, scriptName, scriptValue) {
105
- const multiline = /\r?\n/.test(source);
106
- const eol = source.includes('\r\n') ? '\r\n' : '\n';
107
- const rootPropertyIndent = source.match(/\r?\n([ \t]+)"[^"\n]+"\s*:/)?.[1] ?? ' ';
108
- const indentUnit = rootPropertyIndent;
109
- const encoded = JSON.stringify(scriptValue);
110
- const key = JSON.stringify(scriptName);
111
- const scriptsMatch = /"scripts"\s*:\s*\{/.exec(source);
112
-
113
- if (scriptsMatch) {
114
- const open = source.indexOf('{', scriptsMatch.index);
115
- let depth = 0;
116
- let quoted = false;
117
- let escaped = false;
118
- let close = -1;
119
- for (let index = open; index < source.length; index += 1) {
120
- const char = source[index];
121
- if (quoted) {
122
- if (escaped) escaped = false;
123
- else if (char === '\\') escaped = true;
124
- else if (char === '"') quoted = false;
125
- continue;
126
- }
127
- if (char === '"') quoted = true;
128
- else if (char === '{') depth += 1;
129
- else if (char === '}' && --depth === 0) {
130
- close = index;
131
- break;
132
- }
133
- }
134
- if (close === -1) throw new Error('Unbalanced package.json scripts object');
135
- const body = source.slice(open + 1, close);
136
- if (!multiline) {
137
- const addition = body.trim() ? `,${key}:${encoded}` : `${key}:${encoded}`;
138
- return `${source.slice(0, close)}${addition}${source.slice(close)}`;
139
- }
140
- const beforeClose = source.slice(0, close);
141
- const trailing = beforeClose.match(/\s*$/)?.[0] ?? '';
142
- const contentEnd = close - trailing.length;
143
- const closingIndent = trailing.slice(trailing.lastIndexOf('\n') + 1);
144
- const propertyIndent = `${closingIndent}${indentUnit}`;
145
- const addition = body.trim()
146
- ? `,${eol}${propertyIndent}${key}: ${encoded}`
147
- : `${propertyIndent}${key}: ${encoded}`;
148
- return `${source.slice(0, contentEnd)}${addition}${eol}${closingIndent}${source.slice(close)}`;
149
- }
150
-
151
- const rootClose = source.lastIndexOf('}');
152
- if (rootClose === -1) throw new Error('Unbalanced package.json object');
153
- const rootBody = source.slice(0, rootClose);
154
- if (!multiline) {
155
- const separator = rootBody.trim().endsWith('{') ? '' : ',';
156
- return `${rootBody}${separator}"scripts":{${key}:${encoded}}${source.slice(rootClose)}`;
157
- }
158
- const trailing = rootBody.match(/\s*$/)?.[0] ?? '';
159
- const contentEnd = rootClose - trailing.length;
160
- const rootClosingIndent = trailing.slice(trailing.lastIndexOf('\n') + 1);
161
- const separator = source.slice(0, contentEnd).trimEnd().endsWith('{') ? '' : ',';
162
- const addition = `${separator}${eol}${rootPropertyIndent}"scripts": {${eol}${rootPropertyIndent}${indentUnit}${key}: ${encoded}${eol}${rootPropertyIndent}}`;
163
- return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
105
+ return addPackageJsonPropertyPreservingFormat(
106
+ source,
107
+ 'scripts',
108
+ scriptName,
109
+ scriptValue,
110
+ 'package.json scripts'
111
+ );
164
112
  }
165
113
 
166
114
  /**
@@ -243,10 +191,11 @@ ${rows}`;
243
191
  * Load project layers for AGENTS generation. Returns null when config is absent/invalid
244
192
  * so callers fall back to the stock 11-layer table.
245
193
  * @param {string} root
194
+ * @param {string} [configPath] contract file (defaults to `<root>/ark.config.json`)
246
195
  */
247
- export function loadConfigLayersForAgents(root) {
196
+ export function loadConfigLayersForAgents(root, configPath = path.join(root, 'ark.config.json')) {
248
197
  try {
249
- const cfgPath = path.join(root, 'ark.config.json');
198
+ const cfgPath = configPath;
250
199
  if (!fs.existsSync(cfgPath)) return null;
251
200
  const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
252
201
  if (!Array.isArray(cfg?.layers) || cfg.layers.length === 0) return null;
@@ -638,10 +587,7 @@ ${setupSteps ? `${setupSteps}\n` : ''} - name: Setup Node
638
587
  uses: actions/setup-node@v4
639
588
  with:
640
589
  ${nodeSetup}
641
- cache: ${pm.cache}
642
- - name: Install dependencies
643
- run: ${pm.install}
644
- ${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
590
+ ${pm.cache ? ` cache: ${pm.cache}\n` : ''}${pm.install ? ` - name: Install dependencies\n run: ${pm.install}\n` : ''}${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
645
591
  env:
646
592
  ARK_POLICY_BASE_REF: \${{ github.event.pull_request.base.sha || github.event.before }}
647
593
  run: |
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  export const CI_MERGE_BOUNDARY_REL = '.ark/ci-merge-boundary.json';
9
- export const CI_MERGE_BOUNDARY_SCHEMA = '1.0';
9
+ const CI_MERGE_BOUNDARY_SCHEMA = '1.0';
10
10
 
11
11
  /**
12
12
  * @param {{