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
@@ -10,6 +10,72 @@
10
10
 
11
11
  /** Keep in lockstep with arkRulesTypes.ARK_RULE_TIER2_SENSOR_IDS (self-contained for CLI gen). */
12
12
  const ARK_RULE_TIER2_SENSOR_IDS = ['no-anemic-model'];
13
+ /**
14
+ * Shared Domain + rulesInventory guard/publish vocabulary (DSHAPE-001).
15
+ * One list: sensors and inventory suggestions must not drift.
16
+ */
17
+ export const DOMAIN_INVARIANT_WORDS = [
18
+ 'ensureInvariants',
19
+ 'assertInvariants',
20
+ 'validate',
21
+ 'publish',
22
+ 'emit',
23
+ 'raise',
24
+ 'record',
25
+ ];
26
+ /** No `g` flag: `.test` must not advance lastIndex. */
27
+ export const DOMAIN_INVARIANT_WORD_RE = new RegExp(`\\b(${DOMAIN_INVARIANT_WORDS.join('|')})\\b`);
28
+ const EVENTS_ARRAY_PROP = '(?:_?pendingEvents|domainEvents|uncommittedEvents|recordedEvents)';
29
+ export const DOMAIN_EVENTS_PUSH_RE = new RegExp(`\\bthis\\.${EVENTS_ARRAY_PROP}\\.push\\s*\\(`);
30
+ const EVENTS_ARRAY_RESET_RE = new RegExp(`^this\\.${EVENTS_ARRAY_PROP}\\s*=\\s*\\[\\s*\\]`);
31
+ const ANY_THIS_EMPTY_ARRAY_RE = /^this\.[A-Za-z_][A-Za-z0-9_]*\s*=\s*\[\s*\]/;
32
+ const THIS_FIELD_ASSIGNMENT_RE = /\bthis\.[A-Za-z_][A-Za-z0-9_]*\s*=(?!=)/g;
33
+ const SHAPE_TRUNCATED_UNTIL = 'truncatedUntil';
34
+ export function expectedDomainInvariantWordsPhrase() {
35
+ return `${DOMAIN_INVARIANT_WORDS.join(', ')}, or events-array .push(`;
36
+ }
37
+ export function referencesGuardOrPublish(source) {
38
+ return DOMAIN_INVARIANT_WORD_RE.test(source) || DOMAIN_EVENTS_PUSH_RE.test(source);
39
+ }
40
+ export function isIdiomaticEventsReset(source, assignIndex, methodName) {
41
+ const slice = source.slice(assignIndex);
42
+ if (EVENTS_ARRAY_RESET_RE.test(slice))
43
+ return true;
44
+ if (!ANY_THIS_EMPTY_ARRAY_RE.test(slice))
45
+ return false;
46
+ if (methodName && /^pullEvents$/i.test(methodName))
47
+ return true;
48
+ const windowStart = assignIndex > 200 ? assignIndex - 200 : 0;
49
+ return /\bpullEvents\b/.test(source.slice(windowStart, assignIndex + 200));
50
+ }
51
+ function methodAssignsThis(methodName, methodBody) {
52
+ const re = new RegExp(THIS_FIELD_ASSIGNMENT_RE.source, 'g');
53
+ let match;
54
+ while ((match = re.exec(methodBody)) !== null) {
55
+ if (isIdiomaticEventsReset(methodBody, match.index, methodName))
56
+ continue;
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+ 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;
70
+ }
71
+ function shapeTruncatedUntil(shape) {
72
+ const value = Object.getOwnPropertyDescriptor(shape, SHAPE_TRUNCATED_UNTIL)?.value;
73
+ return typeof value === 'number' ? value : undefined;
74
+ }
75
+ function shapeTruncationSuffix(shape) {
76
+ const until = shapeTruncatedUntil(shape);
77
+ return until == null ? '' : ` shape analysed until character ${until}`;
78
+ }
13
79
  /**
14
80
  * Glob to RegExp for appliesTo. Keep in lockstep with layerMatch.globToRegExp
15
81
  * (zero path segments for double-star-slash; self-contained for generate:cli-pure).
@@ -152,10 +218,15 @@ function evaluateAlwaysValidFactory(rule, shapes, layerForFile) {
152
218
  }
153
219
  function evaluateDomainEventOnMutation(rule, shapes, layerForFile) {
154
220
  const out = [];
221
+ const expected = expectedDomainInvariantWordsPhrase();
155
222
  for (const shape of shapesForRule(rule, shapes, layerForFile)) {
223
+ 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
+ }
156
227
  for (const method of shape.mutatingMethods) {
157
228
  if (!method.referencesGuardOrPublish) {
158
- out.push(baseViolation(rule, shape.file, `Mutating method ${shape.className}.${method.name} does not reference a guard or publish symbol (sensor domain-event-on-mutation).`));
229
+ out.push(baseViolation(rule, shape.file, `Mutating method ${shape.className}.${method.name} does not reference ${expected} (sensor domain-event-on-mutation).${truncation}`));
159
230
  }
160
231
  }
161
232
  }
@@ -307,10 +378,39 @@ export function collectEmptyAppliesToFindings(arkRules, files) {
307
378
  a.arkruleId.localeCompare(b.arkruleId) ||
308
379
  a.message.localeCompare(b.message));
309
380
  }
310
- /** IO / ORM import evidence (mirrors design-smells; kept local for Domain purity). */
311
- const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm|typeorm|knex|mongodb|pg|mysql2|mongoose|better-sqlite3|ioredis|redis|kysely|sequelize)['"]|require\(\s*['"](?:@?prisma\/client|pg|knex|typeorm|mongoose)/;
312
- /** Write tokens that skip the aggregate when paired with a persistence driver import. */
313
- const PERSISTENCE_WRITE_HINT_RE = /\.(?: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;
381
+ /** IO / ORM import evidence. postgres and drizzle-orm include package subpaths. Keep in lockstep with arkOrderFacts. */
382
+ 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)/;
383
+ /**
384
+ * Path-alias / local db module (`@/lib/db`) without resolving tsconfig.
385
+ * Keep in lockstep with arkOrderFacts.
386
+ */
387
+ const IO_ALIAS_IMPORT_RE = /\bfrom\s+['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle)(?:\.[cm]?[jt]sx?)?['"]|require\(\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle)/;
388
+ /**
389
+ * Write tokens that skip the aggregate when paired with a persistence driver import.
390
+ * Callee must be db|tx|client|prisma|drizzle (PrismaClient included); not repo.update(.
391
+ * Keep in lockstep with arkOrderFacts.
392
+ */
393
+ 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;
394
+ export function isPersistenceDriverLayer(layer) {
395
+ return layer === 'PersistenceAdapters';
396
+ }
397
+ export function sourceImportsPersistenceDriver(content, resolvedImports) {
398
+ if (IO_IMPORT_HINT_RE.test(content) || IO_ALIAS_IMPORT_RE.test(content))
399
+ return true;
400
+ if (!resolvedImports)
401
+ return false;
402
+ for (const imp of resolvedImports) {
403
+ if (isPersistenceDriverLayer(imp.layer))
404
+ return true;
405
+ const specifier = imp.specifier;
406
+ if (!specifier)
407
+ continue;
408
+ const synthetic = `from '${specifier}'`;
409
+ if (IO_IMPORT_HINT_RE.test(synthetic) || IO_ALIAS_IMPORT_RE.test(synthetic))
410
+ return true;
411
+ }
412
+ return false;
413
+ }
314
414
  const HANDLER_SHAPE_HINT_RE = /\b(?:@Controller|@Get|@Post|@Put|@Delete|Router\(\)|createRouter|express\.Router|fastify\.(?:get|post)|export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b|export\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=)/;
315
415
  const FRAMEWORK_HTTP_HINT_RE = /(?:^|[;\n])\s*(?:import\s+(?:type\s+)?(?:[^;]{0,512}?\s+from\s+)?|export\s+(?:type\s+)?[^;]{0,512}?\s+from\s+)['"]next\/server(?:\.js)?['"]/;
316
416
  /** Business-predicate / domain branching signals (conservative). */
@@ -321,9 +421,15 @@ const BUSINESS_BRANCH_HINT_RE = /\bif\s*\(\s*(?:!)?(?:order|invoice|cart|user|ac
321
421
  * Prefers false negatives over false positives (ADR 0013 discipline).
322
422
  * Returns null when neither flag is set (callers may omit the path).
323
423
  */
324
- export function deriveArkRuleFileHints(_file, content) {
325
- if (!content || content.length < 40)
424
+ export function deriveArkRuleFileHints(_file, content, resolvedImports) {
425
+ if (!content)
326
426
  return null;
427
+ const hasIo = sourceImportsPersistenceDriver(content, resolvedImports);
428
+ const persistenceWrite = hasIo && PERSISTENCE_WRITE_HINT_RE.test(content);
429
+ // Orchestration/adapter heuristics need a longer window; writes still fire on short probes.
430
+ if (content.length < 40) {
431
+ return persistenceWrite ? { persistenceWrite: true } : null;
432
+ }
327
433
  const domainPredicates = content.match(new RegExp(DOMAIN_PREDICATE_HINT_RE.source, 'g')) ?? [];
328
434
  const businessBranches = content.match(new RegExp(BUSINESS_BRANCH_HINT_RE.source, 'g')) ?? [];
329
435
  const ifCount = (content.match(/\bif\s*\(/g) ?? []).length;
@@ -334,7 +440,6 @@ export function deriveArkRuleFileHints(_file, content) {
334
440
  (domainPredicates.length >= 1 && businessBranches.length >= 2) ||
335
441
  (businessBranches.length >= 3 && ifCount + switchCount >= 6);
336
442
  // Adapter-thick: multi-concern mixing — domain branching + persistence/HTTP in one module.
337
- const hasIo = IO_IMPORT_HINT_RE.test(content);
338
443
  const hasHandler = HANDLER_SHAPE_HINT_RE.test(content) || FRAMEWORK_HTTP_HINT_RE.test(content);
339
444
  const hasDomainSignal = domainPredicates.length >= 1 || businessBranches.length >= 2;
340
445
  const hasMapping = /\b(?:mapTo|toDomain|toDto|fromRow|toEntity|fromPrisma|serialize|deserialize)\w*\s*[(=]/.test(content);
@@ -342,7 +447,6 @@ export function deriveArkRuleFileHints(_file, content) {
342
447
  (hasHandler && hasDomainSignal) ||
343
448
  (hasIo && hasMapping && (ifCount >= 4 || domainPredicates.length >= 1)) ||
344
449
  (hasHandler && hasIo); // hollow-persistence style: HTTP + persistence together
345
- const persistenceWrite = hasIo && PERSISTENCE_WRITE_HINT_RE.test(content);
346
450
  if (!orchestrationHeavy && !adapterThick && !persistenceWrite)
347
451
  return null;
348
452
  return {
@@ -354,15 +458,218 @@ export function deriveArkRuleFileHints(_file, content) {
354
458
  /**
355
459
  * Build fileHints map from path→content. Omits paths with no flags (sparse map).
356
460
  */
357
- export function buildArkRuleFileHints(fileContents) {
461
+ export function buildArkRuleFileHints(fileContents, resolvedImportsByFile) {
358
462
  const out = {};
359
463
  for (const [file, content] of Object.entries(fileContents)) {
360
- const hint = deriveArkRuleFileHints(file, content);
464
+ const rel = file.replace(/\\/g, '/');
465
+ const hint = deriveArkRuleFileHints(file, content, resolvedImportsByFile?.[rel]);
361
466
  if (hint)
362
- out[file.replace(/\\/g, '/')] = hint;
467
+ out[rel] = hint;
363
468
  }
364
469
  return out;
365
470
  }
471
+ const MEMBER_MODIFIERS = new Set([
472
+ 'public',
473
+ 'private',
474
+ 'protected',
475
+ 'static',
476
+ 'async',
477
+ 'readonly',
478
+ 'abstract',
479
+ 'override',
480
+ 'declare',
481
+ 'get',
482
+ 'set',
483
+ ]);
484
+ const CONTROL_FLOW_METHOD_NAMES = new Set(['if', 'match', 'when']);
485
+ function skipStringOrComment(src, index) {
486
+ const ch = src[index];
487
+ if (ch === '/' && src[index + 1] === '/') {
488
+ const nl = src.indexOf('\n', index);
489
+ return nl === -1 ? src.length : nl;
490
+ }
491
+ if (ch === '/' && src[index + 1] === '*') {
492
+ const end = src.indexOf('*/', index + 2);
493
+ return end === -1 ? src.length : end + 2;
494
+ }
495
+ if (ch === "'" || ch === '"' || ch === '`') {
496
+ let j = index + 1;
497
+ while (j < src.length) {
498
+ if (src[j] === '\\') {
499
+ j += 2;
500
+ continue;
501
+ }
502
+ if (src[j] === ch)
503
+ return j + 1;
504
+ j += 1;
505
+ }
506
+ return src.length;
507
+ }
508
+ return index;
509
+ }
510
+ function skipWsAndComments(src, index) {
511
+ let i = index;
512
+ while (i < src.length) {
513
+ if (/\s/.test(src[i])) {
514
+ i += 1;
515
+ continue;
516
+ }
517
+ if (src[i] === '/' && (src[i + 1] === '/' || src[i + 1] === '*')) {
518
+ i = skipStringOrComment(src, i);
519
+ continue;
520
+ }
521
+ break;
522
+ }
523
+ return i;
524
+ }
525
+ function readIdent(src, index) {
526
+ const ch = src[index];
527
+ if (!ch || !/[A-Za-z_]/.test(ch))
528
+ return null;
529
+ let j = index + 1;
530
+ while (j < src.length && /[A-Za-z0-9_]/.test(src[j]))
531
+ j += 1;
532
+ return { ident: src.slice(index, j), end: j };
533
+ }
534
+ function skipBalanced(src, openIndex, openCh, closeCh) {
535
+ if (src[openIndex] !== openCh)
536
+ return null;
537
+ let depth = 1;
538
+ let i = openIndex + 1;
539
+ while (i < src.length && depth > 0) {
540
+ const skipped = skipStringOrComment(src, i);
541
+ if (skipped !== i) {
542
+ i = skipped;
543
+ continue;
544
+ }
545
+ const ch = src[i];
546
+ if (ch === openCh)
547
+ depth += 1;
548
+ else if (ch === closeCh)
549
+ depth -= 1;
550
+ i += 1;
551
+ }
552
+ return depth === 0 ? i : null;
553
+ }
554
+ function scanClassMembers(body) {
555
+ const members = [];
556
+ let i = 0;
557
+ let truncatedAt;
558
+ while (i < body.length) {
559
+ i = skipWsAndComments(body, i);
560
+ if (i >= body.length)
561
+ break;
562
+ if (body[i] === ';') {
563
+ i += 1;
564
+ continue;
565
+ }
566
+ const modifiers = [];
567
+ let cursor = i;
568
+ while (true) {
569
+ const tok = readIdent(body, cursor);
570
+ if (!tok || !MEMBER_MODIFIERS.has(tok.ident))
571
+ break;
572
+ modifiers.push(tok.ident);
573
+ cursor = skipWsAndComments(body, tok.end);
574
+ }
575
+ const nameTok = readIdent(body, cursor);
576
+ if (!nameTok) {
577
+ i += 1;
578
+ continue;
579
+ }
580
+ cursor = skipWsAndComments(body, nameTok.end);
581
+ if (body[cursor] === '<') {
582
+ const afterGeneric = skipBalanced(body, cursor, '<', '>');
583
+ if (afterGeneric == null) {
584
+ truncatedAt = body.length;
585
+ break;
586
+ }
587
+ cursor = skipWsAndComments(body, afterGeneric);
588
+ }
589
+ if (body[cursor] === '(') {
590
+ const afterParen = skipBalanced(body, cursor, '(', ')');
591
+ if (afterParen == null) {
592
+ truncatedAt = body.length;
593
+ break;
594
+ }
595
+ cursor = skipWsAndComments(body, afterParen);
596
+ if (body[cursor] === ':') {
597
+ cursor += 1;
598
+ while (cursor < body.length && body[cursor] !== '{' && body[cursor] !== ';') {
599
+ const skipped = skipStringOrComment(body, cursor);
600
+ if (skipped !== cursor) {
601
+ cursor = skipped;
602
+ continue;
603
+ }
604
+ cursor += 1;
605
+ }
606
+ }
607
+ if (body[cursor] === '{') {
608
+ const afterBrace = skipBalanced(body, cursor, '{', '}');
609
+ if (afterBrace == null) {
610
+ truncatedAt = body.length;
611
+ break;
612
+ }
613
+ members.push({
614
+ name: nameTok.ident,
615
+ modifiers,
616
+ kind: 'method',
617
+ body: body.slice(cursor + 1, afterBrace - 1),
618
+ });
619
+ i = afterBrace;
620
+ continue;
621
+ }
622
+ if (body[cursor] === ';') {
623
+ i = cursor + 1;
624
+ continue;
625
+ }
626
+ i = cursor + 1;
627
+ continue;
628
+ }
629
+ let depthBrace = 0;
630
+ let depthParen = 0;
631
+ let depthBracket = 0;
632
+ while (cursor < body.length) {
633
+ const skipped = skipStringOrComment(body, cursor);
634
+ if (skipped !== cursor) {
635
+ cursor = skipped;
636
+ continue;
637
+ }
638
+ const ch = body[cursor];
639
+ if (ch === '{')
640
+ depthBrace += 1;
641
+ else if (ch === '}') {
642
+ if (depthBrace === 0)
643
+ break;
644
+ depthBrace -= 1;
645
+ }
646
+ else if (ch === '(')
647
+ depthParen += 1;
648
+ else if (ch === ')')
649
+ depthParen -= 1;
650
+ else if (ch === '[')
651
+ depthBracket += 1;
652
+ else if (ch === ']')
653
+ depthBracket -= 1;
654
+ else if (ch === ';' &&
655
+ depthBrace === 0 &&
656
+ depthParen === 0 &&
657
+ depthBracket === 0) {
658
+ cursor += 1;
659
+ break;
660
+ }
661
+ cursor += 1;
662
+ }
663
+ members.push({
664
+ name: nameTok.ident,
665
+ modifiers,
666
+ kind: 'field',
667
+ body: '',
668
+ });
669
+ i = cursor;
670
+ }
671
+ return { members, truncatedAt };
672
+ }
366
673
  /**
367
674
  * Lightweight class-shape extraction from TypeScript source text (no compiler).
368
675
  * Conservative: prefers false negatives over false positives for mutability.
@@ -393,80 +700,69 @@ export function extractClassShapesFromSource(file, content) {
393
700
  i += 1;
394
701
  }
395
702
  const body = content.slice(start, i - 1);
396
- // P1-L prefer false negatives: readonly public props are not mutable state.
397
- // Strip only readonly property *declarations*, not whole lines (co-located
398
- // `public readonly id; public count = 0` must keep the mutable field).
399
- // Also ignore comment lines that merely mention the word "readonly".
400
- const bodyNoReadonly = body
401
- .split('\n')
402
- .map((line) => {
403
- if (/^\s*\/\//.test(line) || /^\s*\/\*|\*\//.test(line))
404
- return line;
405
- // Remove `public readonly foo` / `readonly foo` decls; leave other decls.
406
- return line
407
- .replace(/(?:public\s+|protected\s+)?readonly\s+[a-zA-Z_][a-zA-Z0-9_]*\s*(?::[^=;]+)?(?:=\s*[^;]+)?[;,]?/g, '')
408
- .replace(/(?:^|[\s;{])readonly\s+[a-zA-Z_][a-zA-Z0-9_]*\s*(?::[^=;]+)?(?:=\s*[^;]+)?[;,]?/g, ' ');
409
- })
410
- .join('\n');
411
- const hasPublicMutableFields = /(?:^|\n)\s*(?:public\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(bodyNoReadonly.replace(/(?:public\s+|private\s+|protected\s+|static\s+|async\s+|get\s+|set\s+)/g, '')) &&
412
- /(?:^|\n)\s*(public\s+)?(?!constructor|static|get|set|private|protected|readonly)[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(bodyNoReadonly);
413
- // Public mutable field: "public foo" (not readonly) or unadorned assignable field.
414
- const publicField = /(?:^|\n)\s*public\s+(?!static|async|get|set|constructor|readonly)[a-zA-Z_]/.test(bodyNoReadonly) ||
415
- /(?:^|[\n;])\s*[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*[^=;\n]+[;=]/m.test(bodyNoReadonly
416
- .split('\n')
417
- .filter((line) => !/^\s*(private|protected|static|constructor|get |set |async |\/)/.test(line))
418
- .join('\n'));
703
+ const classUnclosed = depth > 0;
704
+ const scanned = scanClassMembers(body);
705
+ const truncatedUntil = classUnclosed
706
+ ? content.length
707
+ : scanned.truncatedAt == null
708
+ ? undefined
709
+ : start + scanned.truncatedAt;
710
+ const publicMutableFields = scanned.members.filter((member) => {
711
+ if (member.kind !== 'field')
712
+ return false;
713
+ if (member.name === 'constructor')
714
+ return false;
715
+ if (member.modifiers.includes('private') || member.modifiers.includes('protected')) {
716
+ return false;
717
+ }
718
+ if (member.modifiers.includes('readonly'))
719
+ return false;
720
+ if (member.modifiers.includes('static'))
721
+ return false;
722
+ if (member.modifiers.includes('get') || member.modifiers.includes('set'))
723
+ return false;
724
+ return true;
725
+ });
726
+ const hasPublicMutableFields = publicMutableFields.length > 0;
419
727
  const hasPublicSetters = /(?:^|[\n;{])\s*(?:public\s+)?set\s+[a-zA-Z_]/.test(body);
420
728
  const hasPrivateConstructor = /(?:^|[\n;{])\s*private\s+constructor\s*\(/.test(body);
421
729
  const hasPublicConstructor = /(?:^|[\n;{])\s*(?:public\s+)?constructor\s*\(/.test(body) && !hasPrivateConstructor;
422
730
  const hasStaticFactory = /(?:^|[\n;{])\s*static\s+(?:async\s+)?(?:create|of|from|parse|build|make|new)\s*[<(]/.test(body) ||
423
731
  /(?:^|[\n;{])\s*static\s+(?:async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)\s*:\s*[A-Za-z_]/.test(body);
424
732
  const mutatingMethods = [];
425
- // P1-L / P1L-STRUCTURE-NOISE-CONTROL-FLOW: fluent control-flow helpers (Property.if,
426
- // .match, .when) often assign this.* for chaining — not domain mutators. Prefer FN.
427
- const CONTROL_FLOW_METHOD_NAMES = new Set(['if', 'match', 'when']);
428
- const methodRe = /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+|async\s+)*(?!constructor|get|set|static)([a-zA-Z_][a-zA-Z0-9_]*)\s*\([^)]*\)\s*(?::\s*[^{]+)?\{/g;
429
- let methodMatch;
430
- while ((methodMatch = methodRe.exec(body)) !== null) {
431
- const name = methodMatch[1];
432
- if (CONTROL_FLOW_METHOD_NAMES.has(name))
733
+ for (const member of scanned.members) {
734
+ if (member.kind !== 'method')
433
735
  continue;
434
- const mStart = methodMatch.index + methodMatch[0].length;
435
- let mDepth = 1;
436
- let j = mStart;
437
- while (j < body.length && mDepth > 0) {
438
- if (body[j] === '{')
439
- mDepth += 1;
440
- else if (body[j] === '}')
441
- mDepth -= 1;
442
- j += 1;
443
- }
444
- const methodBody = body.slice(mStart, j - 1);
445
- const assignsThis = /this\.\w+\s*=/.test(methodBody);
446
- if (!assignsThis)
736
+ if (member.name === 'constructor')
447
737
  continue;
448
- const referencesGuardOrPublish = /\b(ensureInvariants|assertInvariants|validate|publish|emit|raise|record)\b/.test(methodBody);
449
- mutatingMethods.push({ name, referencesGuardOrPublish });
738
+ if (member.modifiers.includes('static'))
739
+ continue;
740
+ if (member.modifiers.includes('get') || member.modifiers.includes('set'))
741
+ continue;
742
+ if (CONTROL_FLOW_METHOD_NAMES.has(member.name))
743
+ continue;
744
+ if (!methodAssignsThis(member.name, member.body))
745
+ continue;
746
+ mutatingMethods.push({
747
+ name: member.name,
748
+ referencesGuardOrPublish: referencesGuardOrPublish(member.body),
749
+ });
450
750
  }
451
- const methodCount = (body.match(/(?:^|\n)\s*(?:public\s+|private\s+|protected\s+)?(?:async\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*\(/g) ?? []).length;
452
- // P1-L — raise anemic bar: need multiple public fields and essentially no behavior.
453
- // Single-field bags and intentional property bags with one helper stay quiet.
454
- // Count fields after line starts *or* after `;` so one-line class bodies still work.
455
- const publicFieldCount = (bodyNoReadonly.match(/(?:^|[\n;])\s*(?:public\s+)?(?!constructor|static|get|set|private|protected|readonly)[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/g) ?? []).length;
751
+ const methodCount = scanned.members.filter((member) => member.kind === 'method').length;
456
752
  const dataOnly = methodCount <= 1 &&
457
- publicFieldCount >= 2 &&
458
- (publicField || hasPublicMutableFields);
459
- shapes.push({
753
+ publicMutableFields.length >= 2 &&
754
+ hasPublicMutableFields;
755
+ shapes.push(attachShapeTruncation({
460
756
  file,
461
757
  className,
462
758
  exported: true,
463
- hasPublicMutableFields: publicField || hasPublicMutableFields,
759
+ hasPublicMutableFields,
464
760
  hasPublicSetters,
465
761
  hasPublicConstructor,
466
762
  hasStaticFactory,
467
763
  mutatingMethods: [...mutatingMethods],
468
764
  dataOnly,
469
- });
765
+ }, truncatedUntil));
470
766
  }
471
767
  return shapes;
472
768
  }
@@ -8,19 +8,55 @@
8
8
  * Pure CLI helper (bin/lib/baseline-key.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
+ /** Config diagnostics that are never code debt. Not a schema key. */
12
+ export const NON_FREEZABLE_BASELINE_RULE_IDS = ['ARKRULE_SCOPE_EMPTY'];
13
+ /**
14
+ * STRUCTURE freeze `target`: sensor id, plus `:symbol` when a method/class is known.
15
+ * V1 empty-target keys (`ARKRULE_STRUCTURE|file|layer||`) stay exact-match only —
16
+ * they must not prefix-silence every later sensor on that file.
17
+ */
18
+ export function structureFreezeTarget(input) {
19
+ if (typeof input.target === 'string' && input.target.length > 0) {
20
+ return input.target;
21
+ }
22
+ let sensor = String(input.sensor || input.code || '').trim();
23
+ if (!sensor && typeof input.message === 'string') {
24
+ const named = input.message.match(/\(sensor ([a-z0-9-]+)\)/i);
25
+ if (named?.[1])
26
+ sensor = named[1];
27
+ }
28
+ const symbol = String(input.symbol || '').trim();
29
+ if (!sensor)
30
+ return symbol;
31
+ return symbol ? `${sensor}:${symbol}` : sensor;
32
+ }
33
+ /**
34
+ * Config diagnostics (empty ArkRule scope) are not freezable even with `--force`.
35
+ * There is no `allowEmptyScope` key — land the rule as advisory, then promote.
36
+ */
37
+ export function isFreezableBaselineViolation(violation) {
38
+ if (violation.freezable === false)
39
+ return false;
40
+ const ruleId = typeof violation.ruleId === 'string' ? violation.ruleId : '';
41
+ return !NON_FREEZABLE_BASELINE_RULE_IDS.includes(ruleId);
42
+ }
11
43
  /**
12
44
  * Stable key used by `--baseline` / `--update-baseline` to match frozen debt.
13
45
  * Field order and empty-string fallbacks are part of the CLI contract.
14
46
  *
15
47
  * Same string is the ACS06 finding `targetKey` (baseline-compatible).
48
+ * New `ARKRULE_STRUCTURE` freezes put sensor (+ optional symbol) in `target`.
16
49
  */
17
50
  export function baselineKey(violation) {
51
+ const target = violation.ruleId === 'ARKRULE_STRUCTURE'
52
+ ? structureFreezeTarget(violation)
53
+ : (violation.target ?? '');
18
54
  return [
19
55
  violation.ruleId,
20
56
  violation.file,
21
57
  violation.fromLayer ?? '',
22
58
  violation.toLayer ?? '',
23
- violation.target ?? '',
59
+ target,
24
60
  ].join('|');
25
61
  }
26
62
  /**
@@ -31,12 +67,20 @@ export function baselineKey(violation) {
31
67
  * adding a second identical violation is therefore new debt instead of being
32
68
  * silently suppressed by the first occurrence's key.
33
69
  *
70
+ * Non-freezable findings (`freezable: false` or `ARKRULE_SCOPE_EMPTY`) emit an
71
+ * empty string so `baselineRecordsDocument` drops them on write and `--baseline`
72
+ * never matches them (index-preserving for ratchet zip).
73
+ *
34
74
  * ACS06 multi-turn adapters must use these keys as `targetKey` so occurrence
35
75
  * identity matches the freeze ratchet (never orphan baselines).
36
76
  */
37
77
  export function baselineOccurrenceKeys(violations) {
38
78
  const counts = new Map();
39
79
  return violations.map((violation) => {
80
+ // Empty freeze identity: write drops Boolean-falsy keys; match never suppresses.
81
+ if (!isFreezableBaselineViolation(violation)) {
82
+ return '';
83
+ }
40
84
  const base = baselineKey(violation);
41
85
  const occurrence = (counts.get(base) ?? 0) + 1;
42
86
  counts.set(base, occurrence);
@@ -105,7 +105,9 @@ export const ARK_CONFIG_SCHEMA = {
105
105
  allowDisabledPeerIsolation: false,
106
106
  },
107
107
  },
108
- /** Invariant-coverage scan controls (test globs + file budget). Absence keeps defaults. */
108
+ /** Invariant-coverage scan controls (test globs + file budget). Absence keeps defaults.
109
+ * maxFiles also bounds structural-hint preload (orchestration-only / thin-adapter /
110
+ * writes-via-aggregate). There is no arkrules.hintBudget. */
109
111
  coverage: { $ref: '#/$defs/coverage' },
110
112
  /** ADR 0012 — layer name → relative path to arkrules/<Layer>.json */
111
113
  arkRules: {
@@ -203,10 +205,14 @@ export const ARK_CONFIG_SCHEMA = {
203
205
  coverage: {
204
206
  type: 'object',
205
207
  additionalProperties: false,
206
- description: 'Invariant coverage scan controls. testGlobs replaces the built-in test-name heuristic; maxFiles raises or lowers the evidence file budget; coverageRoots declares where the project runs its tests, so a covering test found outside them is reported instead of silently certifying an invariant.',
208
+ description: 'Invariant coverage scan controls. testGlobs replaces the built-in test-name heuristic; maxFiles raises or lowers the evidence file budget and also bounds structural-hint preload for orchestration-only, thin-adapter, and writes-via-aggregate (default 400; there is no arkrules.hintBudget); coverageRoots declares where the project runs its tests, so a covering test found outside them is reported instead of silently certifying an invariant.',
207
209
  properties: {
208
210
  testGlobs: { ...stringArraySchema, minItems: 1 },
209
- maxFiles: { type: 'integer', minimum: 1 },
211
+ maxFiles: {
212
+ type: 'integer',
213
+ minimum: 1,
214
+ description: 'Evidence file budget (default 400) and structural-hint preload cap for orchestration-only, thin-adapter, and writes-via-aggregate. Raise this when hinted/governed counts show truncated sensors. There is no separate arkrules.hintBudget.',
215
+ },
210
216
  coverageRoots: { ...stringArraySchema, minItems: 1 },
211
217
  },
212
218
  },
@@ -55,6 +55,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
55
55
  entry('ARKRULE_STRUCTURE', 'arkrules', 'ArkRule structure sensor failed', 'An opt-in ArkRules structure sensor (private state, factory shape, event publish, persistence write outside an aggregate, …) failed on a governed file for a declared arkruleId.', 'Restore the declared structure for the ArkRule (see arkruleSource), then preflight again. Do not demote the rule without a hash-bound policy acknowledgement.'),
56
56
  entry('ARKRULE_INVARIANT', 'arkrules', 'ArkRule invariant failed', 'Reserved / remediation-recognized code for invariant-plane failures bound to an ArkRule id (coverage path also emits INVARIANT_UNCOVERED).', 'Fix the invariant for the ArkRule declared in arkrules/<Layer>.json, then preflight again. Do not demote without acknowledgement.'),
57
57
  entry('ARKRULE_SCOPE_EMPTY', 'arkrules', 'ArkRule appliesTo matched zero files', 'An ArkRule’s appliesTo globs matched no governed files — the rule cannot observe what it claims to protect.', 'Fix appliesTo globs so they match governed files, or remove the rule. Enforced empty scope fails; advisory empty scope warns.', { oftenAdvisory: true }),
58
+ entry('ARKRULE_HINT_BUDGET_EXHAUSTED', 'arkrules', 'Structural-hint budget exhausted', 'orchestration-only, thin-adapter, and writes-via-aggregate only evaluate files the hint loader preloaded. When eligible governed files exceed that budget (coverage.maxFiles, default 400 — there is no arkrules.hintBudget), those sensors never saw the rest of their scope. Enforced + unreviewed is not green. The finding names exact hinted/governed counts and per-sensor reviewed N/M of scope.', 'Raise coverage.maxFiles in ark.config.json (this cap also bounds structural-hint preload; --doctor names the coupling) so hinted/governed counts match, then re-run with --strict-config. An enforced hint sensor that cannot see its scope fails strict.'),
58
59
  entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial). Kind is never-had-tests (adopt residual) vs tests-disappeared (suite exists).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Treat never-had-tests as adopt residual; treat tests-disappeared as a regression. Missing test globs report partial — never fake green. When the message reports an exhausted file budget, raise coverage.maxFiles (or narrow coverage.testGlobs) in ark.config.json.'),
59
60
  entry('INVARIANT_COVERAGE_OUTSIDE_ROOTS', 'arkrules', 'Covering test outside the declared coverage roots', 'The only test naming this invariant sits outside coverage.coverageRoots — the places the project declares its runner executes. ArkGate matches declared text and never executes tests, so it cannot tell whether that file is ever run: coverage there is a test that exists, not a test that runs.', 'Move the test under a declared coverage root, or add its root to coverage.coverageRoots in ark.config.json. Advisory: it never fails strict, but promotion to enforced refuses on it.', { oftenAdvisory: true }),
60
61
  // ── ArkRun (opt-in extra; RN05 dual-depth nextAction) ────────────────────
@@ -359,7 +359,7 @@ export function printDoctorDetailsHuman(view) {
359
359
  `${violations.length} total${typeNote}${supNote}${activeCount > 0 ? ` — ${activeCount} NOT baselined` : ''}`
360
360
  );
361
361
  for (const edge of summary.edges.slice(0, 3)) line(' ', color.dim(`${edge.count} ${edge.edge}`));
362
- if (summary.concentrated) {
362
+ if (summary.concentrated && typeof summary.dominant === 'string' && summary.dominant.includes(' → ')) {
363
363
  line(warn, color.dim(`${Math.round(summary.dominantShare * 100)}% on one edge (${summary.dominant}) — likely a contract fix, not debt`));
364
364
  }
365
365
  }
@@ -474,7 +474,7 @@ export function printDoctorDetailsHuman(view) {
474
474
  console.log('');
475
475
  console.log(color.bold('Baseline'));
476
476
  if (!baseline.exists) {
477
- line(!analysisComplete || violations.length > 0 ? warn : ok, !analysisComplete ? 'No baseline — current violations were not fully evaluated' : violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline' : 'No baseline (nothing to freeze)');
477
+ line(!analysisComplete || violations.length > 0 ? warn : ok, !analysisComplete ? 'No baseline — current violations were not fully evaluated' : violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline --force --contract-session --author <steward>' : 'No baseline (nothing to freeze)');
478
478
  } else {
479
479
  const baseMark = !analysisComplete || baselineHonesty.dirtyBaselineRisk ? warn : ok;
480
480
  line(baseMark, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
@@ -482,7 +482,7 @@ export function printDoctorDetailsHuman(view) {
482
482
  line(warn, baselineHonesty.message);
483
483
  }
484
484
  if (analysisComplete && staleBaseline > 0) {
485
- line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline`);
485
+ line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline --force --contract-session --author <steward>`);
486
486
  }
487
487
  }
488
488