arkgate 4.8.9 → 4.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -3
- package/README.md +4 -4
- package/bin/ark-check-runtime.mjs +2 -2
- package/bin/ark.mjs +18 -10
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +91 -4
- package/bin/lib/ark-order-facts.mjs +11 -4
- package/bin/lib/arkrule-file-hints.mjs +255 -20
- package/bin/lib/arkrules-sensors.mjs +364 -68
- package/bin/lib/baseline-key.mjs +45 -1
- package/bin/lib/config-contract.mjs +9 -3
- package/bin/lib/diagnostic-catalog.mjs +1 -0
- package/bin/lib/doctor-human.mjs +3 -3
- package/bin/lib/doctor-next-actions.mjs +3 -1
- package/bin/lib/field-install.mjs +23 -2
- package/bin/lib/first-run-help.mjs +69 -5
- package/bin/lib/resolved-candidate-facts.mjs +82 -1
- package/bin/lib/rules-inventory.mjs +7 -3
- package/bin/lib/upstream-report.mjs +330 -0
- package/bin/lib/violations.mjs +51 -15
- package/dist/{diagnosticCatalog-BrkOiwCk.d.ts → diagnosticCatalog-biferT4R.d.ts} +9 -3
- package/dist/eslint/index.cjs +4 -7
- package/dist/eslint/index.js +4 -7
- package/dist/index.cjs +28 -31
- package/dist/index.d.ts +7 -4
- package/dist/index.js +28 -31
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/runtime/index.cjs +11 -11
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +11 -11
- package/docs/README.md +3 -3
- package/docs/agent-guide.md +25 -1
- package/docs/ai-gates.md +8 -0
- package/docs/brownfield-adoption.md +30 -0
- package/docs/configuration.md +14 -1
- package/docs/diagnostics.md +10 -0
- package/docs/package-surface.md +4 -3
- package/docs/use.md +11 -0
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +3 -2
- package/server.json +2 -2
- package/templates/agent-skills/ark-explore/SKILL.md +22 -1
- 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
|
|
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
|
|
311
|
-
const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm
|
|
312
|
-
/**
|
|
313
|
-
|
|
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
|
|
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
|
|
464
|
+
const rel = file.replace(/\\/g, '/');
|
|
465
|
+
const hint = deriveArkRuleFileHints(file, content, resolvedImportsByFile?.[rel]);
|
|
361
466
|
if (hint)
|
|
362
|
-
out[
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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 =
|
|
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
|
-
|
|
458
|
-
|
|
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
|
|
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
|
}
|
package/bin/lib/baseline-key.mjs
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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) ────────────────────
|
package/bin/lib/doctor-human.mjs
CHANGED
|
@@ -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
|
|