arkgate 4.8.2 → 4.8.4
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 +257 -3
- package/README.md +47 -9
- package/bin/ark-check-runtime.mjs +340 -5
- package/bin/ark-layer-match.mjs +170 -13
- package/bin/ark-mcp-runtime.mjs +9 -2
- package/bin/lib/analysis-completeness.mjs +86 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +2 -0
- package/bin/lib/ark-order-facts.mjs +59 -0
- package/bin/lib/ark-order-sensors.mjs +31 -2
- package/bin/lib/arkrule-file-hints.mjs +6 -2
- package/bin/lib/arkrules-contract.mjs +9 -1
- package/bin/lib/arkrules-sensors.mjs +22 -2
- package/bin/lib/check-args.mjs +66 -0
- package/bin/lib/config-contract.mjs +26 -0
- package/bin/lib/config-extras.mjs +2 -0
- package/bin/lib/design-smells.mjs +85 -0
- package/bin/lib/diagnostic-catalog.mjs +8 -2
- package/bin/lib/first-run-help.mjs +12 -0
- package/bin/lib/invariant-coverage-io.mjs +175 -19
- package/bin/lib/invariant-coverage.mjs +110 -7
- package/bin/lib/literal-path-drift-io.mjs +569 -0
- package/bin/lib/literal-path-drift.mjs +761 -0
- package/bin/lib/policy-delta-io.mjs +5 -0
- package/bin/lib/remediation.mjs +24 -1
- package/bin/lib/resolved-candidate-facts.mjs +31 -0
- package/bin/lib/rules-under-contract.mjs +5 -0
- package/bin/lib/scan-files.mjs +54 -0
- package/bin/lib/sensor-promote-cli.mjs +372 -0
- package/bin/lib/sensor-promote-io.mjs +246 -0
- package/bin/lib/sensor-promotion.mjs +363 -0
- package/dist/{configTypes-BdCe_gvv.d.ts → configTypes-dy5PfTqS.d.ts} +36 -0
- package/dist/{diagnosticCatalog-CPzH-MLN.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +169 -11
- package/dist/eslint/index.cjs +5 -5
- package/dist/eslint/index.d.ts +34 -1
- package/dist/eslint/index.js +5 -5
- package/dist/index.cjs +31 -31
- package/dist/index.d.ts +85 -7
- package/dist/index.js +31 -31
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/runtime/index.cjs +13 -13
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +13 -13
- package/dist/{types-DCSlrRnV.d.ts → types-BuM8WNqe.d.ts} +1 -1
- package/dist/{types-C9KApBzX.d.ts → types-D95drJ3_.d.ts} +1 -1
- package/docs/README.md +4 -4
- package/docs/agent-guide.md +182 -0
- package/docs/configuration.md +89 -9
- package/docs/develop.md +24 -2
- package/docs/diagnostics.md +79 -1
- package/docs/enthusiast/README.md +6 -4
- package/docs/package-surface.md +36 -4
- package/docs/product-voice.md +15 -5
- package/docs/use.md +8 -5
- package/package.json +2 -2
- package/schemas/ark.arkrules.schema.json +1 -0
- package/schemas/ark.config.schema.json +72 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +3 -3
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +13 -3
- package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
- package/templates/agent-skills/ark-contract/SKILL.md +4 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
- package/templates/agent-skills/ark-place/SKILL.md +6 -2
- package/templates/arkrules/ApplicationOrchestration.json +6 -0
- package/templates/skills/ark-adopt.md +13 -3
- package/templates/skills/ark-autopilot.md +1 -1
- package/templates/skills/ark-contract.md +4 -0
- package/templates/skills/ark-coverage.md +1 -0
- package/templates/skills/ark-place.md +6 -2
|
@@ -97,10 +97,20 @@ import {
|
|
|
97
97
|
import { loadArkConfigContract, parseArkConfigJson } from './lib/config-contract.mjs';
|
|
98
98
|
import { checkUsage, checkUsageAll } from './lib/first-run-help.mjs';
|
|
99
99
|
import { createAdapterResult } from './lib/adapter-contract.mjs';
|
|
100
|
-
import {
|
|
100
|
+
import {
|
|
101
|
+
UNGOVERNED_PROBE_CAP,
|
|
102
|
+
collectGovernedFiles,
|
|
103
|
+
countUngovernedSourceFiles,
|
|
104
|
+
normalize,
|
|
105
|
+
walk,
|
|
106
|
+
} from './lib/scan-files.mjs';
|
|
101
107
|
import { configWarning } from './lib/config-warnings.mjs';
|
|
102
108
|
import { runArchitectureScan } from './lib/architecture-scan.mjs';
|
|
103
|
-
import {
|
|
109
|
+
import {
|
|
110
|
+
ANALYSIS_COMPLETENESS,
|
|
111
|
+
analysisIncompleteStatement,
|
|
112
|
+
emptyAnalysisRefusal,
|
|
113
|
+
} from './lib/analysis-completeness.mjs';
|
|
104
114
|
import { reportUnavailableAnalysis } from './lib/unavailable-analysis.mjs';
|
|
105
115
|
import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
|
|
106
116
|
import {
|
|
@@ -574,6 +584,201 @@ function runAdoptContract(args) {
|
|
|
574
584
|
}
|
|
575
585
|
}
|
|
576
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Literal path drift (LPD) — repo paths that live inside strings, comments and
|
|
589
|
+
* docstrings, and no longer resolve.
|
|
590
|
+
*
|
|
591
|
+
* `tsc` resolves imports, not strings, and ESLint does not either, so this
|
|
592
|
+
* whole class compiles green. Report only by default (house convention:
|
|
593
|
+
* plan-by-default, `--write` to mutate) and only the ANCHORED findings are ever
|
|
594
|
+
* written — an unanchored one has no destination to propose.
|
|
595
|
+
*/
|
|
596
|
+
async function runPathDrift(args) {
|
|
597
|
+
const root = args.root;
|
|
598
|
+
const { scanLiteralPathDrift, writeLiteralPathDrift } = await import(
|
|
599
|
+
'./lib/literal-path-drift-io.mjs'
|
|
600
|
+
);
|
|
601
|
+
let config;
|
|
602
|
+
try {
|
|
603
|
+
config = readConfig(root, args.config);
|
|
604
|
+
} catch {
|
|
605
|
+
// The scan reads text, not the contract's rules: a contract too broken to
|
|
606
|
+
// parse must not hide the drift. `include` only widens the roots.
|
|
607
|
+
config = { include: ['src'] };
|
|
608
|
+
}
|
|
609
|
+
const baseRef = resolveDesignDeltaBaseRef(root, args.baseRef);
|
|
610
|
+
const report = scanLiteralPathDrift(root, config, { baseRef, tsconfig: args.tsconfig });
|
|
611
|
+
const written = args.write ? writeLiteralPathDrift(root, report.anchored) : null;
|
|
612
|
+
// After a write the findings that were applied no longer exist on disk, so
|
|
613
|
+
// reporting them as findings would describe a tree that is already gone.
|
|
614
|
+
const remainingAnchored = written
|
|
615
|
+
? report.anchored.filter((finding) => !wasApplied(finding, written))
|
|
616
|
+
: report.anchored;
|
|
617
|
+
// Both sinks describe the tree as it now stands: after a write the applied
|
|
618
|
+
// findings are gone from disk, and printing them as findings would contradict
|
|
619
|
+
// the "wrote ..." lines directly underneath.
|
|
620
|
+
const shown = { ...report, anchored: remainingAnchored };
|
|
621
|
+
|
|
622
|
+
// The unanchored sweep is opt-in (`--all`). On a repo that WRITES about paths
|
|
623
|
+
// it produced 4085 candidates out of 9536 literals, almost all of them
|
|
624
|
+
// illustrative paths in prose and help text — that is ArkGate's inability to
|
|
625
|
+
// resolve a string reported as a fact about the user's code, the same defect
|
|
626
|
+
// class as the coverage budget. The count is always printed, so opting out of
|
|
627
|
+
// the list is never opting out of knowing.
|
|
628
|
+
const payload = {
|
|
629
|
+
...report,
|
|
630
|
+
anchored: remainingAnchored,
|
|
631
|
+
unanchoredCount: report.unanchoredCount,
|
|
632
|
+
unanchored: args.all ? report.unanchored : [],
|
|
633
|
+
unanchoredListed: args.all,
|
|
634
|
+
};
|
|
635
|
+
if (args.json) {
|
|
636
|
+
console.log(JSON.stringify({ pathDrift: payload, ...(written ? { written } : {}) }, null, 2));
|
|
637
|
+
} else {
|
|
638
|
+
printPathDrift(root, args, shown, written);
|
|
639
|
+
}
|
|
640
|
+
// Anchored drift is a fact about the tree: the source is gone and a rename
|
|
641
|
+
// says where it went. Unanchored drift is advisory — ArkGate cannot tell a
|
|
642
|
+
// dead reference from one it simply cannot resolve.
|
|
643
|
+
// Three outcomes, and CI must be able to tell them apart from the exit code
|
|
644
|
+
// alone — a tick the terminal withholds is no use to a pipeline that only
|
|
645
|
+
// reads the status:
|
|
646
|
+
// 0 anchored mode ran and found nothing left
|
|
647
|
+
// 1 anchored drift remains
|
|
648
|
+
// 2 anchored mode could not run (no usable base ref) — this run proved
|
|
649
|
+
// nothing, and exiting 0 here would be the false green one level down.
|
|
650
|
+
if (!report.renameSet.available) process.exitCode = 2;
|
|
651
|
+
else process.exitCode = remainingAnchored.length > 0 ? 1 : 0;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* True when THIS finding was one of the replacements written.
|
|
656
|
+
*
|
|
657
|
+
* Matched by identity, not by file: a file holding two findings where only one
|
|
658
|
+
* still matched its token must keep the other one in the remaining set.
|
|
659
|
+
*/
|
|
660
|
+
function wasApplied(finding, written) {
|
|
661
|
+
const entry = written.written.find((row) => row.file === finding.file);
|
|
662
|
+
if (!entry) return false;
|
|
663
|
+
return (entry.appliedFindings ?? []).some(
|
|
664
|
+
(applied) =>
|
|
665
|
+
applied.line === finding.line &&
|
|
666
|
+
applied.column === finding.column &&
|
|
667
|
+
applied.token === finding.token
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* A path from git or from the filesystem is raw bytes, and it is about to be
|
|
673
|
+
* printed to a terminal. A control character there can repaint or erase the
|
|
674
|
+
* findings above it.
|
|
675
|
+
*/
|
|
676
|
+
function renderPath(value) {
|
|
677
|
+
return String(value).replace(/[\u0000-\u001f\u007f]/g, (ch) =>
|
|
678
|
+
`\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function printPathDrift(root, args, report, written) {
|
|
683
|
+
console.log(color.bold('Literal path drift'));
|
|
684
|
+
console.log(
|
|
685
|
+
color.dim(
|
|
686
|
+
` scanned ${report.scannedFiles} text file(s), ${report.candidates} path-shaped literal(s)`
|
|
687
|
+
)
|
|
688
|
+
);
|
|
689
|
+
if (!report.renameSet.available) {
|
|
690
|
+
console.log(
|
|
691
|
+
color.yellow(
|
|
692
|
+
` No rename set (${report.renameSet.reason}) — anchored mode is OFF, so nothing below carries a suggested replacement. Pass --base-ref <git-ref> to enable it.`
|
|
693
|
+
)
|
|
694
|
+
);
|
|
695
|
+
} else {
|
|
696
|
+
console.log(
|
|
697
|
+
color.dim(
|
|
698
|
+
` rename set vs ${report.baseRef}: ${report.renameSet.renames} rename(s), ${report.anchorsConsidered} usable anchor(s)`
|
|
699
|
+
)
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (report.ambiguousAnchors.length > 0) {
|
|
703
|
+
console.log(
|
|
704
|
+
color.dim(
|
|
705
|
+
` ${report.ambiguousAnchors.length} rename source(s) map to more than one destination and anchor nothing (e.g. ${renderPath(report.ambiguousAnchors[0])})`
|
|
706
|
+
)
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const discarded = report.scan.discarded;
|
|
710
|
+
const dropped = Object.values(discarded).reduce((sum, n) => sum + n, 0);
|
|
711
|
+
if (dropped > 0) {
|
|
712
|
+
console.log(
|
|
713
|
+
color.dim(
|
|
714
|
+
` not read: ${discarded.generated} generated, ${discarded.oversize} oversize, ${discarded.budget} past the ${report.scan.maxFiles}-file budget, ${discarded.byteBudget} past the ${Math.round(report.scan.maxTotalBytes / (1024 * 1024))}MB total budget, ${discarded.unreadable} unreadable, ${discarded.depthLimited} past the depth limit, ${discarded.symlink} symlinked file(s), ${discarded.symlinkDir} symlinked director(ies)`
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
for (const finding of report.anchored) {
|
|
720
|
+
console.log(
|
|
721
|
+
`${color.red('\u2716')} ${finding.ruleId} ${renderPath(finding.file)}:${finding.line} [${finding.form}]`
|
|
722
|
+
);
|
|
723
|
+
console.log(
|
|
724
|
+
finding.suggestedToken === null
|
|
725
|
+
? ` ${renderPath(finding.token)} -> ${renderPath(finding.suggestedTarget)} (outside the alias root of this literal — rewrite by hand)`
|
|
726
|
+
: ` ${renderPath(finding.token)} -> ${renderPath(finding.suggestedToken)}`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (report.unanchoredCount > 0) {
|
|
730
|
+
if (args.all) {
|
|
731
|
+
for (const finding of report.unanchored) {
|
|
732
|
+
console.log(
|
|
733
|
+
`${color.yellow('warning')} ${finding.ruleId} ${renderPath(finding.file)}:${finding.line} [${finding.form}] ${renderPath(finding.token)}`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
console.log(
|
|
738
|
+
color.yellow(
|
|
739
|
+
` ${report.unanchoredCount} unanchored candidate(s)${args.all ? ` listed above${report.truncated.unanchored ? ` (first ${report.unanchored.length}; the list is capped at ${report.findingCap}, the count is not)` : ''}` : ' not listed (--all)'} — literals that look like a repo path and do not resolve. Advisory only: with no rename to anchor them ArkGate cannot tell a dead reference from an illustrative one, so read them, do not gate on them.`
|
|
740
|
+
)
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (written) {
|
|
745
|
+
for (const entry of written.written) {
|
|
746
|
+
console.log(color.green(` wrote ${renderPath(entry.file)} (${entry.applied})`));
|
|
747
|
+
}
|
|
748
|
+
for (const entry of written.skipped) {
|
|
749
|
+
console.log(color.yellow(` skipped ${renderPath(entry.file)}: ${entry.reason} (${entry.count})`));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (report.anchored.length === 0) {
|
|
754
|
+
if (!report.renameSet.available) {
|
|
755
|
+
// No tick. A green mark over a check that never ran is the false green
|
|
756
|
+
// this whole patch exists to remove.
|
|
757
|
+
console.log(
|
|
758
|
+
color.yellow(
|
|
759
|
+
'\u25CB Anchored mode did not run — no rename set. This says nothing about drift.'
|
|
760
|
+
)
|
|
761
|
+
);
|
|
762
|
+
} else {
|
|
763
|
+
console.log(color.green('\u2714 No anchored literal path drift.'));
|
|
764
|
+
console.log(
|
|
765
|
+
color.dim(
|
|
766
|
+
' Every literal explained by the rename set resolves. This is a text match over strings and comments: it proves no scanned literal is stale against those renames, not that every path in the repo is live.'
|
|
767
|
+
)
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (!written && report.anchored.length > 0) {
|
|
772
|
+
const writable = report.anchored.filter((finding) => finding.suggestedToken !== null).length;
|
|
773
|
+
const byHand = report.anchored.length - writable;
|
|
774
|
+
console.log(
|
|
775
|
+
color.dim(
|
|
776
|
+
` Report only. Re-run with --write to apply ${writable} of the ${report.anchored.length} anchored replacement(s)${byHand > 0 ? `; ${byHand} must be rewritten by hand` : ''}. Unanchored findings are never written.`
|
|
777
|
+
)
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
577
782
|
/**
|
|
578
783
|
* Additive P0-A contract retrofit: inject high-spec app/api → Application when missing.
|
|
579
784
|
* Does not remove existing patterns or weaken rules (DL-P0A-RETROFIT).
|
|
@@ -849,6 +1054,9 @@ function applyConfigRootWalkUp(args) {
|
|
|
849
1054
|
return args;
|
|
850
1055
|
}
|
|
851
1056
|
const writeMode = isMutatingCliCommand(args);
|
|
1057
|
+
// The root the caller asked for, before any walk-up adopts the config's directory.
|
|
1058
|
+
// An empty analysis must be able to say which of the two it actually walked.
|
|
1059
|
+
args.requestedRoot = path.resolve(args.root);
|
|
852
1060
|
const effective = resolveEffectiveProjectRoot(args.root, {
|
|
853
1061
|
configName: args.config,
|
|
854
1062
|
writeMode,
|
|
@@ -924,6 +1132,17 @@ async function main() {
|
|
|
924
1132
|
return;
|
|
925
1133
|
}
|
|
926
1134
|
|
|
1135
|
+
if (args.pathDrift) {
|
|
1136
|
+
await runPathDrift(args);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (args.sensors) {
|
|
1141
|
+
const { runSensors } = await import('./lib/sensor-promote-cli.mjs');
|
|
1142
|
+
await runSensors(args, readConfig);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
927
1146
|
if (args.recommend) {
|
|
928
1147
|
try {
|
|
929
1148
|
const recommendation = buildArchitectureRecommendation(args.root);
|
|
@@ -964,6 +1183,59 @@ async function main() {
|
|
|
964
1183
|
return;
|
|
965
1184
|
}
|
|
966
1185
|
|
|
1186
|
+
// Empty analysis is a refusal: on zero governed files every later gate reports on
|
|
1187
|
+
// nothing. It outranks --require-gates, so the caller hears the real reason instead
|
|
1188
|
+
// of "Ark gates are not installed" in whatever directory the contract happened to
|
|
1189
|
+
// live in — but it is evaluated LAZILY, so the cheap exits (a --changed run whose
|
|
1190
|
+
// diff touches no product path, --require-gates with the gates present) still pay
|
|
1191
|
+
// nothing for a filesystem walk they never needed.
|
|
1192
|
+
//
|
|
1193
|
+
// Report modes are exempt: --plan, --coverage and --doctor are how a user sees and
|
|
1194
|
+
// fixes an empty scope (they already carry the `empty-scope` adoption gap), so
|
|
1195
|
+
// refusing there would remove the only surface that explains the refusal.
|
|
1196
|
+
const root = args.root;
|
|
1197
|
+
const verdictPath = !args.plan && !args.coverage && !args.doctor;
|
|
1198
|
+
let configCache = null;
|
|
1199
|
+
const loadConfig = () => (configCache ??= readConfig(root, args.config));
|
|
1200
|
+
let governedCache = null;
|
|
1201
|
+
const loadGovernedFiles = () => (governedCache ??= collectGovernedFiles(root, loadConfig()));
|
|
1202
|
+
const emptyAnalysisRefusalNow = () => {
|
|
1203
|
+
const governedCount = loadGovernedFiles().length;
|
|
1204
|
+
return emptyAnalysisRefusal({
|
|
1205
|
+
governedFileCount: governedCount,
|
|
1206
|
+
// Probed only when nothing is governed, and never through the contract's own
|
|
1207
|
+
// exclude: the config under suspicion must not get to answer the question about
|
|
1208
|
+
// itself (`exclude: ["**"]` would otherwise read as greenfield and pass).
|
|
1209
|
+
ungovernedSourceCount: governedCount === 0 ? countUngovernedSourceFiles(root) : 0,
|
|
1210
|
+
ungovernedSourceCap: UNGOVERNED_PROBE_CAP,
|
|
1211
|
+
root,
|
|
1212
|
+
requestedRoot: args.requestedRoot,
|
|
1213
|
+
configPath: path.isAbsolute(args.config) ? args.config : path.join(root, args.config),
|
|
1214
|
+
configWalkedUp: args.configWalkedUp === true,
|
|
1215
|
+
});
|
|
1216
|
+
};
|
|
1217
|
+
const reportEmptyAnalysis = (refusal) => {
|
|
1218
|
+
if (args.json) {
|
|
1219
|
+
console.log(
|
|
1220
|
+
JSON.stringify(
|
|
1221
|
+
{
|
|
1222
|
+
ok: false,
|
|
1223
|
+
error: refusal.ruleId,
|
|
1224
|
+
completeness: ANALYSIS_COMPLETENESS.unavailable,
|
|
1225
|
+
message: refusal.message,
|
|
1226
|
+
nextAction: refusal.nextAction,
|
|
1227
|
+
},
|
|
1228
|
+
null,
|
|
1229
|
+
2
|
|
1230
|
+
)
|
|
1231
|
+
);
|
|
1232
|
+
} else {
|
|
1233
|
+
console.error(`${color.red('\u2716')} ${refusal.ruleId} ${refusal.message}`);
|
|
1234
|
+
console.error(`Next: ${refusal.nextAction}`);
|
|
1235
|
+
}
|
|
1236
|
+
process.exitCode = 1;
|
|
1237
|
+
};
|
|
1238
|
+
|
|
967
1239
|
if (args.requireGates || args.requireWriteHook) {
|
|
968
1240
|
let writeRequest = null;
|
|
969
1241
|
if (args.requireWriteHook) {
|
|
@@ -994,6 +1266,24 @@ async function main() {
|
|
|
994
1266
|
missing.push(`${writeRequest.host} hard-write hook`);
|
|
995
1267
|
}
|
|
996
1268
|
if (missing.length > 0) {
|
|
1269
|
+
// "Gates not installed" is the wrong reason for a run that would have analyzed
|
|
1270
|
+
// nothing: it sends the user to `ark init` for a problem they do not have. Only
|
|
1271
|
+
// here do we pay for the walk — with the gates present we fall through and the
|
|
1272
|
+
// verdict path below checks at its usual point. A contract that cannot even be
|
|
1273
|
+
// read is not evidence of an empty analysis, so that throw falls back to the
|
|
1274
|
+
// gate report instead of masking it.
|
|
1275
|
+
let refusal = null;
|
|
1276
|
+
if (verdictPath) {
|
|
1277
|
+
try {
|
|
1278
|
+
refusal = emptyAnalysisRefusalNow();
|
|
1279
|
+
} catch {
|
|
1280
|
+
refusal = null;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (refusal) {
|
|
1284
|
+
reportEmptyAnalysis(refusal);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
997
1287
|
const payload = {
|
|
998
1288
|
ok: false,
|
|
999
1289
|
error: 'missing-gates',
|
|
@@ -1037,10 +1327,9 @@ async function main() {
|
|
|
1037
1327
|
}
|
|
1038
1328
|
}
|
|
1039
1329
|
|
|
1040
|
-
const root = args.root;
|
|
1041
1330
|
const bound = bindTeamBaseRefs(args, root);
|
|
1042
1331
|
Object.assign(args, bound.args);
|
|
1043
|
-
const config =
|
|
1332
|
+
const config = loadConfig();
|
|
1044
1333
|
const policyDelta = analyzePolicyTransition({
|
|
1045
1334
|
root,
|
|
1046
1335
|
configPath: args.config,
|
|
@@ -1088,7 +1377,14 @@ async function main() {
|
|
|
1088
1377
|
}
|
|
1089
1378
|
const manifest = readManifest(root, args.manifest);
|
|
1090
1379
|
const rules = manifest?.architecture?.rules ?? config.rules;
|
|
1091
|
-
const allGovernedFiles =
|
|
1380
|
+
const allGovernedFiles = loadGovernedFiles();
|
|
1381
|
+
if (verdictPath) {
|
|
1382
|
+
const refusal = emptyAnalysisRefusalNow();
|
|
1383
|
+
if (refusal) {
|
|
1384
|
+
reportEmptyAnalysis(refusal);
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1092
1388
|
if (args.failUngoverned && teamParliament?.changeSet?.productPaths?.length) {
|
|
1093
1389
|
const governedRel = new Set(
|
|
1094
1390
|
allGovernedFiles.map((abs) => normalize(path.relative(root, abs)))
|
|
@@ -1164,6 +1460,31 @@ async function main() {
|
|
|
1164
1460
|
populatedLayerCount,
|
|
1165
1461
|
});
|
|
1166
1462
|
|
|
1463
|
+
// --promote reuses the analysis that just ran rather than running its own:
|
|
1464
|
+
// the advisory findings it counts are already in `violations` + `warnings`,
|
|
1465
|
+
// stamped with the rule that produced them. Placed before the design-delta
|
|
1466
|
+
// check so a preview does not pay for a base-ref diff it never reads.
|
|
1467
|
+
if (args.promote) {
|
|
1468
|
+
// The floor the merge gate itself applies: below it every enforced
|
|
1469
|
+
// extra-plane finding is demoted to a warning, so a promotion made here
|
|
1470
|
+
// buys a label and not a tooth. `violations` above was already demoted by
|
|
1471
|
+
// it; the preview has to know, or it sells teeth the gate then removes.
|
|
1472
|
+
const { extraMergeTeethAllowed } = await import('./lib/extra-merge-teeth.mjs');
|
|
1473
|
+
const teethDemotedByFloor = !extraMergeTeethAllowed({
|
|
1474
|
+
governedPercent: preCov.governed?.percent ?? null,
|
|
1475
|
+
populatedLayerCount,
|
|
1476
|
+
});
|
|
1477
|
+
const { runPromote } = await import('./lib/sensor-promote-cli.mjs');
|
|
1478
|
+
await runPromote(root, config, args, {
|
|
1479
|
+
files,
|
|
1480
|
+
all: [...violations, ...(warnings ?? [])],
|
|
1481
|
+
completeness,
|
|
1482
|
+
completenessReasons,
|
|
1483
|
+
teethDemotedByFloor,
|
|
1484
|
+
});
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1167
1488
|
const createdPathsOnly = Boolean(args.strictMerge && !args.failOnNewSmells);
|
|
1168
1489
|
const designCheck = createDesignDeltaCheck({
|
|
1169
1490
|
enabled: args.failOnNewSmells || args.strictMerge,
|
|
@@ -1670,6 +1991,20 @@ async function main() {
|
|
|
1670
1991
|
`${color.green('✔')} Ark check passed with ${warnings.length} config warning(s).${baselineNote}`
|
|
1671
1992
|
);
|
|
1672
1993
|
}
|
|
1994
|
+
// `--plan` is where the design bets live and a green run never named it.
|
|
1995
|
+
// Not on the strict-config branch above (it printed a failure), not on
|
|
1996
|
+
// `--changed` (a partial scan would print one slice's count as the tree's),
|
|
1997
|
+
// not on `--watch` (a line that repeats every save is a line nobody reads).
|
|
1998
|
+
if (!(args.strictConfig && strictWarnings.length > 0) && !args.changed && !args.watch) {
|
|
1999
|
+
const { greenPlanPointer } = await import('./lib/design-smells.mjs');
|
|
2000
|
+
const pointer = greenPlanPointer({
|
|
2001
|
+
root, config, files, coverage: preCov,
|
|
2002
|
+
blockingViolations: blockingViolations.length,
|
|
2003
|
+
suppressedCount: suppressed.length,
|
|
2004
|
+
planCommand: arkCommand(root, 'ark-check', '--plan'),
|
|
2005
|
+
});
|
|
2006
|
+
if (pointer) console.log(color.dim(pointer));
|
|
2007
|
+
}
|
|
1673
2008
|
} else {
|
|
1674
2009
|
console.error(
|
|
1675
2010
|
blockingViolations.length > 0
|
package/bin/ark-layer-match.mjs
CHANGED
|
@@ -258,20 +258,165 @@ function resolveSliceFolders(rule, layerName, layers) {
|
|
|
258
258
|
const layer = (layers ?? []).find((l) => l.name === layerName);
|
|
259
259
|
return inferSliceFoldersFromPatterns(layer?.patterns);
|
|
260
260
|
}
|
|
261
|
+
function normalizeSegments(value) {
|
|
262
|
+
return String(value)
|
|
263
|
+
.split(/[/\\]/)
|
|
264
|
+
.filter((part) => Boolean(part) && part !== '.')
|
|
265
|
+
.map((part) => part.toLowerCase());
|
|
266
|
+
}
|
|
261
267
|
/**
|
|
262
|
-
*
|
|
268
|
+
* Trim trailing slashes without a regex.
|
|
263
269
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
270
|
+
* `/\/+$/` is a polynomial ReDoS on a value that comes from the repo's own
|
|
271
|
+
* contract but is still library input: a root of many slashes makes the engine
|
|
272
|
+
* retry from every start position. A scan is linear and says the same thing.
|
|
266
273
|
*/
|
|
267
|
-
|
|
268
|
-
|
|
274
|
+
function trimTrailingSlashes(value) {
|
|
275
|
+
let end = value.length;
|
|
276
|
+
while (end > 0 && value[end - 1] === '/')
|
|
277
|
+
end -= 1;
|
|
278
|
+
return value.slice(0, end);
|
|
279
|
+
}
|
|
280
|
+
/** Source folders a declared shared root may sit under without being named. */
|
|
281
|
+
const SHARED_ROOT_SOURCE_PREFIXES = ['src', 'app'];
|
|
282
|
+
/** A root that would disable the wall wholesale is not a root. */
|
|
283
|
+
function isBlanketRoot(raw) {
|
|
284
|
+
const trimmed = trimTrailingSlashes(raw.replace(/^[./]+/, ''));
|
|
285
|
+
return trimmed === '*' || trimmed === '**';
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Is `relPath` under one of the roots the rule declares shared on purpose?
|
|
289
|
+
*
|
|
290
|
+
* **Anchored**, unlike `sliceIdForPath`: the root must start the repo-relative
|
|
291
|
+
* path, optionally after a single conventional source folder, so `ui` covers
|
|
292
|
+
* `ui/button.tsx` and `src/ui/button.tsx` but NOT `modules/a/ui/x.tsx` — an
|
|
293
|
+
* unanchored root would exempt a whole tree the author never declared. Deeper
|
|
294
|
+
* or monorepo roots are written out (`packages/web/src/ui`) or globbed
|
|
295
|
+
* (`packages/*/src/ui`). Matching is case-insensitive; a root containing `*`
|
|
296
|
+
* is matched as a glob (also case-insensitively) against the whole path, and a
|
|
297
|
+
* bare `*` / `**` is refused because it would disable fail-closed wholesale.
|
|
298
|
+
*/
|
|
299
|
+
export function pathUnderSharedRoot(relPath, sharedRoots) {
|
|
300
|
+
if (!relPath || !sharedRoots?.length)
|
|
301
|
+
return false;
|
|
302
|
+
const rel = String(relPath).split(/[/\\]/).join('/');
|
|
303
|
+
const lowerRel = rel.toLowerCase();
|
|
304
|
+
const parts = normalizeSegments(rel);
|
|
305
|
+
for (const raw of sharedRoots) {
|
|
306
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
307
|
+
continue;
|
|
308
|
+
if (isBlanketRoot(raw))
|
|
309
|
+
continue;
|
|
310
|
+
if (raw.includes('*')) {
|
|
311
|
+
const glob = trimTrailingSlashes(raw.toLowerCase());
|
|
312
|
+
if (globToRegExp(glob).test(lowerRel) || globToRegExp(`${glob}/**`).test(lowerRel)) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const root = normalizeSegments(raw);
|
|
318
|
+
if (root.length === 0)
|
|
319
|
+
continue;
|
|
320
|
+
// Anchor at segment 0, or at segment 1 when the path opens with a source
|
|
321
|
+
// folder the root does not itself name.
|
|
322
|
+
const offsets = SHARED_ROOT_SOURCE_PREFIXES.includes(parts[0]) && root[0] !== parts[0] ? [0, 1] : [0];
|
|
323
|
+
for (const offset of offsets) {
|
|
324
|
+
if (offset + root.length > parts.length)
|
|
325
|
+
continue;
|
|
326
|
+
let hit = true;
|
|
327
|
+
for (let j = 0; j < root.length; j += 1) {
|
|
328
|
+
if (parts[offset + j] !== root[j]) {
|
|
329
|
+
hit = false;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (hit)
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
function sliceMatchesDeclaration(declared, sliceId) {
|
|
340
|
+
const want = String(declared).split(/[/\\]/).filter(Boolean).join('/').toLowerCase();
|
|
341
|
+
if (!want)
|
|
342
|
+
return false;
|
|
343
|
+
const have = sliceId.toLowerCase();
|
|
344
|
+
if (want === have)
|
|
269
345
|
return true;
|
|
346
|
+
// Bare slice name: `auth` matches `features/auth`.
|
|
347
|
+
return !want.includes('/') && have.endsWith(`/${want}`);
|
|
348
|
+
}
|
|
349
|
+
/** Has the rule declared this directed slice→slice edge? */
|
|
350
|
+
export function crossSliceEdgeAllowed(allowedCrossSlice, fromSlice, toSlice) {
|
|
351
|
+
if (!allowedCrossSlice?.length || !fromSlice || !toSlice)
|
|
352
|
+
return false;
|
|
353
|
+
return allowedCrossSlice.some((edge) => edge &&
|
|
354
|
+
typeof edge.from === 'string' &&
|
|
355
|
+
typeof edge.to === 'string' &&
|
|
356
|
+
sliceMatchesDeclaration(edge.from, fromSlice) &&
|
|
357
|
+
sliceMatchesDeclaration(edge.to, toSlice));
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* PeerIsolation deny decision with the reason that fired (DF04 pure core).
|
|
361
|
+
*
|
|
362
|
+
* Fail-closed stays fail-closed: absent evidence denies. What changed in 4.8.4
|
|
363
|
+
* is what counts as evidence — a declared shared root, or a declared directed
|
|
364
|
+
* cross-slice edge, is the repo telling us its design, so it is no longer
|
|
365
|
+
* "unclassifiable". Order: no paths → no slice folders → a side that is neither
|
|
366
|
+
* in a slice nor declared shared → same slice → declared cross edge → deny.
|
|
367
|
+
*/
|
|
368
|
+
export function peerIsolationDecision(input) {
|
|
369
|
+
if (!input.fromPath || !input.toPath)
|
|
370
|
+
return { denied: true, reason: 'missing-path' };
|
|
270
371
|
if (input.folderCount <= 0)
|
|
271
|
-
return true;
|
|
372
|
+
return { denied: true, reason: 'no-slice-folders' };
|
|
373
|
+
const fromClassified = Boolean(input.fromSlice) || input.fromShared === true;
|
|
374
|
+
const toClassified = Boolean(input.toSlice) || input.toShared === true;
|
|
375
|
+
if (!fromClassified || !toClassified)
|
|
376
|
+
return { denied: true, reason: 'unclassifiable-path' };
|
|
377
|
+
// At least one side is declared shared (and carries no slice id): the repo
|
|
378
|
+
// said this code belongs to no slice, so there is no cross-slice edge here.
|
|
272
379
|
if (!input.fromSlice || !input.toSlice)
|
|
273
|
-
return
|
|
274
|
-
|
|
380
|
+
return { denied: false };
|
|
381
|
+
if (input.fromSlice === input.toSlice)
|
|
382
|
+
return { denied: false };
|
|
383
|
+
if (input.crossSliceAllowed)
|
|
384
|
+
return { denied: false };
|
|
385
|
+
return { denied: true, reason: 'cross-slice' };
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Boolean face of {@link peerIsolationDecision}, kept for parity consumers.
|
|
389
|
+
*
|
|
390
|
+
* Fail-closed: missing path, no classifiable folders, or unclassifiable either
|
|
391
|
+
* side → deny. Same-slice → allow (return false). Cross-slice → deny unless the
|
|
392
|
+
* rule declared that directed edge.
|
|
393
|
+
*/
|
|
394
|
+
export function peerIsolationMustDeny(input) {
|
|
395
|
+
return peerIsolationDecision(input).denied;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* One human sentence naming which peerIsolation reason fired — so the denial
|
|
399
|
+
* reports a fact about their code (`cross-slice`) or a fact about our evidence
|
|
400
|
+
* (everything else), never one dressed as the other.
|
|
401
|
+
*/
|
|
402
|
+
export function peerIsolationDenyExplanation(reason, context) {
|
|
403
|
+
switch (reason) {
|
|
404
|
+
case 'cross-slice':
|
|
405
|
+
return `cross-slice edge ${context.fromSlice ?? '?'} → ${context.toSlice ?? '?'}. Extract the shared code, use events/ports across slices, or declare the edge in the rule's allowedCrossSlice.`;
|
|
406
|
+
case 'unclassifiable-path': {
|
|
407
|
+
const unplaced = [
|
|
408
|
+
context.fromSlice ? undefined : context.fromPath,
|
|
409
|
+
context.toSlice ? undefined : context.toPath,
|
|
410
|
+
].filter((path) => Boolean(path));
|
|
411
|
+
const which = unplaced.length > 0 ? ` (${unplaced.join(', ')})` : '';
|
|
412
|
+
return `unclassifiable path${which} — ArkGate cannot place it in a slice, so it cannot prove this is not a cross-slice edge. Move it into a slice, or declare its root in the rule's sharedRoots.`;
|
|
413
|
+
}
|
|
414
|
+
case 'no-slice-folders':
|
|
415
|
+
return 'no slice folders — peerIsolation is on but no slice folder resolves from the rule or the layer patterns. Set sliceFolders on the rule.';
|
|
416
|
+
case 'missing-path':
|
|
417
|
+
default:
|
|
418
|
+
return 'no path evidence for this edge — peerIsolation needs the importer and importee paths.';
|
|
419
|
+
}
|
|
275
420
|
}
|
|
276
421
|
/**
|
|
277
422
|
* Find the first denying rule for a layer edge.
|
|
@@ -286,6 +431,14 @@ export function peerIsolationMustDeny(input) {
|
|
|
286
431
|
* allow a possible cross-slice edge.
|
|
287
432
|
*/
|
|
288
433
|
export function findDeniedEdgeRule(rules, from, to, options) {
|
|
434
|
+
return findDeniedEdgeDecision(rules, from, to, options)?.rule;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* {@link findDeniedEdgeRule} with the denial reason attached, so adapters can
|
|
438
|
+
* say *why* a peerIsolation rule fired instead of emitting one opaque message
|
|
439
|
+
* for a real cross-slice import and for a file we simply could not place.
|
|
440
|
+
*/
|
|
441
|
+
export function findDeniedEdgeDecision(rules, from, to, options) {
|
|
289
442
|
for (const rule of rules ?? []) {
|
|
290
443
|
if (rule.from !== from || rule.to !== to)
|
|
291
444
|
continue;
|
|
@@ -297,21 +450,25 @@ export function findDeniedEdgeRule(rules, from, to, options) {
|
|
|
297
450
|
const folders = resolveSliceFolders(rule, from, options?.layers);
|
|
298
451
|
const fromSlice = fromPath && toPath ? sliceIdForPath(fromPath, folders) : undefined;
|
|
299
452
|
const toSlice = fromPath && toPath ? sliceIdForPath(toPath, folders) : undefined;
|
|
300
|
-
|
|
453
|
+
const decision = peerIsolationDecision({
|
|
301
454
|
fromPath,
|
|
302
455
|
toPath,
|
|
303
456
|
folderCount: folders.length,
|
|
304
457
|
fromSlice,
|
|
305
458
|
toSlice,
|
|
306
|
-
|
|
307
|
-
|
|
459
|
+
fromShared: !fromSlice && pathUnderSharedRoot(fromPath, rule.sharedRoots),
|
|
460
|
+
toShared: !toSlice && pathUnderSharedRoot(toPath, rule.sharedRoots),
|
|
461
|
+
crossSliceAllowed: crossSliceEdgeAllowed(rule.allowedCrossSlice, fromSlice, toSlice),
|
|
462
|
+
});
|
|
463
|
+
if (decision.denied) {
|
|
464
|
+
return { rule, peerIsolationReason: decision.reason, fromSlice, toSlice };
|
|
308
465
|
}
|
|
309
|
-
continue; // same slice
|
|
466
|
+
continue; // same slice, declared shared, or declared cross edge: no denial
|
|
310
467
|
}
|
|
311
468
|
// Classic deny — same-layer always allowed without peerIsolation
|
|
312
469
|
if (from === to)
|
|
313
470
|
continue;
|
|
314
|
-
return rule;
|
|
471
|
+
return { rule };
|
|
315
472
|
}
|
|
316
473
|
return undefined;
|
|
317
474
|
}
|
package/bin/ark-mcp-runtime.mjs
CHANGED
|
@@ -260,14 +260,21 @@ async function loadArk() {
|
|
|
260
260
|
const url = new URL('../dist/index.js', import.meta.url);
|
|
261
261
|
if (!fs.existsSync(url)) {
|
|
262
262
|
throw new Error(
|
|
263
|
-
'ark-mcp requires the built library at dist/index.js
|
|
263
|
+
'ark-mcp requires the built library at dist/index.js, and this install does not have it. ' +
|
|
264
|
+
'The npm tarball ships dist/; a git install (git+https://…/arkgate) ships only the ' +
|
|
265
|
+
'committed sources, so arkgate/arkgate-check work but ark-mcp and every "import arkgate*" ' +
|
|
266
|
+
'do not. Install from npm (npm i arkgate); building is only possible in a clone of the ' +
|
|
267
|
+
'repository, not in this node_modules copy, which ships no devDependencies. ' +
|
|
268
|
+
'See docs/package-surface.md, "Installing from git".'
|
|
264
269
|
);
|
|
265
270
|
}
|
|
266
271
|
try {
|
|
267
272
|
return await import('../dist/index.js');
|
|
268
273
|
} catch (err) {
|
|
269
274
|
throw new Error(
|
|
270
|
-
`ark-mcp failed to load dist/index.js
|
|
275
|
+
`ark-mcp failed to load dist/index.js — it exists but will not import, so this is a ` +
|
|
276
|
+
`broken or partial build rather than a git install (rebuild with "npm run build" in a ` +
|
|
277
|
+
`clone of the repository): ${
|
|
271
278
|
err instanceof Error ? err.message : String(err)
|
|
272
279
|
}`
|
|
273
280
|
);
|