arkgate 4.8.24 → 4.8.26
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 +95 -0
- package/README.md +2 -2
- package/bin/ark-check-runtime.mjs +17 -14
- package/bin/ark-layer-match.mjs +115 -13
- package/bin/ark-mcp-runtime.mjs +36 -31
- package/bin/ark-shared.mjs +4 -23
- package/bin/lib/adoption-stance.mjs +2 -2
- package/bin/lib/adr-path.mjs +3 -3
- package/bin/lib/agent-gates.mjs +0 -61
- package/bin/lib/agent-homes.mjs +12 -21
- package/bin/lib/agent-projection-command.mjs +4 -4
- package/bin/lib/ambient-state.mjs +9 -30
- package/bin/lib/analysis-engine.mjs +14 -14
- package/bin/lib/architecture-scan.mjs +11 -9
- package/bin/lib/ark-order-doctor.mjs +3 -28
- package/bin/lib/ark-order-facts.mjs +6 -99
- package/bin/lib/ark-order-types.mjs +1 -2
- package/bin/lib/ark-run-doctor.mjs +3 -28
- package/bin/lib/ark-run-facts.mjs +30 -40
- package/bin/lib/ark-run-sensors.mjs +1 -1
- package/bin/lib/arkrule-file-hints.mjs +2 -47
- package/bin/lib/arkrules-contract.mjs +124 -133
- package/bin/lib/arkrules-sensors.mjs +7 -5
- package/bin/lib/check-args.mjs +63 -1
- package/bin/lib/ci-and-commands.mjs +8 -72
- package/bin/lib/ci-merge-boundary.mjs +1 -1
- package/bin/lib/class-source-scan.mjs +5 -5
- package/bin/lib/clone-detection.mjs +474 -0
- package/bin/lib/codex-home.mjs +1 -1
- package/bin/lib/config-contract-slices.mjs +44 -6
- package/bin/lib/config-contract.mjs +40 -117
- package/bin/lib/config-extras.mjs +16 -22
- package/bin/lib/contract-smells.mjs +33 -15
- package/bin/lib/core-ratchet.mjs +0 -2
- package/bin/lib/dashboard-cli.mjs +2 -2
- package/bin/lib/deep-module-coach.mjs +2 -4
- package/bin/lib/deploy-path.mjs +13 -38
- package/bin/lib/design-smells.mjs +2 -2
- package/bin/lib/diagnostic-catalog.mjs +8 -1
- package/bin/lib/doctor-advisories.mjs +55 -9
- package/bin/lib/doctor-green-cite.mjs +1 -1
- package/bin/lib/doctor-human.mjs +1 -1
- package/bin/lib/doctor-next-actions.mjs +2 -2
- package/bin/lib/doctor-plan.mjs +4 -6
- package/bin/lib/duplication-io.mjs +572 -0
- package/bin/lib/effective-contract-load.mjs +181 -44
- package/bin/lib/entry-points-io.mjs +424 -0
- package/bin/lib/extra-merge-teeth.mjs +36 -0
- package/bin/lib/field-install.mjs +38 -14
- package/bin/lib/first-run-help.mjs +7 -3
- package/bin/lib/flat-parent-importers.mjs +5 -3
- package/bin/lib/gate-files.mjs +30 -89
- package/bin/lib/github-enforcement.mjs +165 -28
- package/bin/lib/golden-pattern.mjs +1 -1
- package/bin/lib/hook-templates.mjs +16 -16
- package/bin/lib/html-report-advisories.mjs +4 -0
- package/bin/lib/html-report-depth.mjs +2 -2
- package/bin/lib/html-report.mjs +2 -2
- package/bin/lib/import-graph-projection.mjs +112 -0
- package/bin/lib/improvement-compass-doctor.mjs +1 -24
- package/bin/lib/invariant-coverage-io.mjs +4 -3
- package/bin/lib/invariant-coverage.mjs +26 -0
- package/bin/lib/invariant-probe-cli.mjs +347 -0
- package/bin/lib/invariant-probe-io.mjs +165 -0
- package/bin/lib/invariant-probe-runner.mjs +268 -0
- package/bin/lib/invariant-probe-sites.mjs +174 -0
- package/bin/lib/invariant-probe-workspace.mjs +183 -0
- package/bin/lib/invariant-probe.mjs +461 -0
- package/bin/lib/invariant-tests-path.mjs +16 -22
- package/bin/lib/literal-path-drift.mjs +4 -2
- package/bin/lib/managed-upgrade.mjs +2 -8
- package/bin/lib/mcp-adoption.mjs +4 -5
- package/bin/lib/mcp-hook-payload.mjs +4 -4
- package/bin/lib/orphan-modules-io.mjs +458 -0
- package/bin/lib/orphan-modules.mjs +251 -0
- package/bin/lib/outside-importers.mjs +197 -0
- package/bin/lib/package-manager.mjs +3 -3
- package/bin/lib/parse-health.mjs +1 -1
- package/bin/lib/persistence-write-hint.mjs +33 -8
- package/bin/lib/pin-dependent-gates.mjs +2 -2
- package/bin/lib/policy-delta-io.mjs +60 -12
- package/bin/lib/post-green-path.mjs +1 -1
- package/bin/lib/prepare-change.mjs +3 -9
- package/bin/lib/prepare-write.mjs +1 -2
- package/bin/lib/presets.mjs +56 -68
- package/bin/lib/product-copy.mjs +1 -6
- package/bin/lib/project-root.mjs +43 -85
- package/bin/lib/resolved-candidate-facts.mjs +90 -62
- package/bin/lib/rules-under-contract.mjs +72 -8
- package/bin/lib/scan-files.mjs +4 -4
- package/bin/lib/schema-validation.mjs +130 -0
- package/bin/lib/sensor-promote-cli.mjs +2 -11
- package/bin/lib/sensor-promote-io.mjs +33 -22
- package/bin/lib/sensor-promotion.mjs +1 -1
- package/bin/lib/skill-catalog-apply.mjs +1 -1
- package/bin/lib/skill-install.mjs +42 -38
- package/bin/lib/snippet-analysis.mjs +1 -1
- package/bin/lib/start-install-recovery.mjs +1 -1
- package/bin/lib/start-preview.mjs +1 -1
- package/bin/lib/states-transitions-presence.mjs +3 -3
- package/bin/lib/status-command.mjs +2 -18
- package/bin/lib/status-manifest.mjs +18 -26
- package/bin/lib/status-transition-catalog.mjs +0 -6
- package/bin/lib/suggestions.mjs +3 -3
- package/bin/lib/team-parliament-io.mjs +3 -17
- package/bin/lib/team-parliament.mjs +15 -9
- package/bin/lib/upgrade-behavior-changes.mjs +2 -2
- package/bin/lib/upgrade-command.mjs +2 -24
- package/bin/lib/upgrade-outcome.mjs +1 -1
- package/bin/lib/upstream-report.mjs +21 -10
- package/bin/lib/violations.mjs +10 -9
- package/bin/lib/weakest-link.mjs +13 -0
- package/bin/lib/write-path-capabilities.mjs +2 -2
- package/dist/chunk-G3JRBJGH.js +16 -0
- package/dist/chunk-R3AJ5RSZ.cjs +16 -0
- package/dist/{configTypes-BJbaeoj4.d.cts → configTypes-GPvvdleM.d.cts} +12 -1
- package/dist/{configTypes-BJbaeoj4.d.ts → configTypes-GPvvdleM.d.ts} +12 -1
- package/dist/eslint/index.cjs +11 -11
- package/dist/eslint/index.d.cts +10 -1
- package/dist/eslint/index.d.ts +10 -1
- package/dist/eslint/index.js +11 -11
- package/dist/index.cjs +34 -34
- package/dist/index.d.cts +587 -515
- package/dist/index.d.ts +587 -515
- package/dist/index.js +34 -34
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +12 -12
- package/dist/runtime/index.d.cts +174 -136
- package/dist/runtime/index.d.ts +174 -136
- package/dist/runtime/index.js +12 -12
- package/dist/{types-DRinG-vT.d.cts → types-CnauMQQL.d.cts} +33 -25
- package/dist/{types-DRinG-vT.d.ts → types-CnauMQQL.d.ts} +33 -25
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +140 -6
- package/docs/ai-gates.md +15 -1
- package/docs/configuration.md +40 -11
- package/docs/diagnostics.md +119 -3
- package/docs/package-surface.md +4 -1
- package/docs/product-voice.md +7 -1
- package/package.json +11 -2
- package/schemas/ark.config.schema.json +31 -3
- package/schemas/ark.invariant-probe.schema.json +368 -0
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
- package/templates/agent-skills/ark-contract/SKILL.md +17 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
- package/templates/agent-skills/ark-explore/SKILL.md +7 -0
- package/templates/agent-skills/ark-fix/SKILL.md +20 -0
- package/templates/agent-skills/ark-place/SKILL.md +8 -0
- package/templates/skills/ark-adopt.md +1 -0
- package/templates/skills/ark-contract.md +17 -0
- package/templates/skills/ark-coverage.md +2 -0
- package/templates/skills/ark-explore.md +7 -0
- package/templates/skills/ark-fix.md +20 -0
- package/templates/skills/ark-place.md +8 -0
- package/bin/lib/ark-order-error.mjs +0 -18
- package/bin/lib/ark-order-invariants.mjs +0 -388
- package/bin/lib/stable-hash.mjs +0 -90
- package/dist/chunk-MBXCFLO4.js +0 -16
- package/dist/chunk-RNQHRDCQ.cjs +0 -16
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Canonical algorithm: src/domain/cloneDetection.ts
|
|
5
|
+
* Regenerate: node scripts/generate-cli-pure.mjs
|
|
6
|
+
* Drift check: node scripts/generate-cli-pure.mjs --check
|
|
7
|
+
*
|
|
8
|
+
* Pure CLI helper (bin/lib/clone-detection.mjs). Zero Node I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ARK_CLONE_DETECTION_SCHEMA_VERSION = '1.0';
|
|
12
|
+
/** Token alphabet: identifiers and literals collapse; every other node is `2 + kind`. */
|
|
13
|
+
export const TOKEN_IDENT = 0;
|
|
14
|
+
/**
|
|
15
|
+
* Literals and JSX text.
|
|
16
|
+
*/
|
|
17
|
+
export const TOKEN_LITERAL = 1;
|
|
18
|
+
/**
|
|
19
|
+
* Every other node kind is emitted as `TOKEN_KIND_OFFSET + kind`.
|
|
20
|
+
*/
|
|
21
|
+
export const TOKEN_KIND_OFFSET = 2;
|
|
22
|
+
/*
|
|
23
|
+
* Fixed constants (ADR 0038 D3). Calibrated on a corpus, not user tunables;
|
|
24
|
+
* they change only through an ADR amendment plus a corpus rerun.
|
|
25
|
+
*/
|
|
26
|
+
/** Tokens per hashed gram (k). */
|
|
27
|
+
export const CLONE_GRAM = 20;
|
|
28
|
+
/** Winnowing window (w). Any shared run of `w + k - 1` tokens shares a fingerprint. */
|
|
29
|
+
export const CLONE_WINDOW = 31;
|
|
30
|
+
/** Minimum extended run, in tokens (`>= CLONE_WINDOW + CLONE_GRAM - 1` keeps the guarantee). */
|
|
31
|
+
export const CLONE_MIN_TOKENS = 50;
|
|
32
|
+
/**
|
|
33
|
+
* Minimum span on each side, in lines.
|
|
34
|
+
*/
|
|
35
|
+
export const CLONE_MIN_LINES = 5;
|
|
36
|
+
/** Share of identifiers that must match position by position. */
|
|
37
|
+
const CLONE_NAME_AGREEMENT = 0.6;
|
|
38
|
+
/** A fingerprint shared by more places than this is boilerplate: skipped and counted. */
|
|
39
|
+
export const CLONE_BUCKET_CAP = 16;
|
|
40
|
+
/** Seed pairs kept before the candidate list is `partial`. */
|
|
41
|
+
const CLONE_MAX_SEED_PAIRS = 50_000;
|
|
42
|
+
/** Families listed. The totals always carry the full count. */
|
|
43
|
+
export const CLONE_FAMILY_LIST_CAP = 8;
|
|
44
|
+
/** Seeds kept per file pair; more seeds are the same copy seen again. */
|
|
45
|
+
const SEEDS_PER_FILE_PAIR = 64;
|
|
46
|
+
export const DUPLICATION_COMMAND = 'arkgate-check --doctor --all';
|
|
47
|
+
const HASH_BASE = 0x01000193;
|
|
48
|
+
const CROSSING_ORDER = {
|
|
49
|
+
'cross-slice': 0,
|
|
50
|
+
'cross-parent': 1,
|
|
51
|
+
'cross-sibling': 2,
|
|
52
|
+
'cross-layer-walled': 3,
|
|
53
|
+
'cross-layer': 4,
|
|
54
|
+
};
|
|
55
|
+
function mix32(value) {
|
|
56
|
+
let h = value >>> 0;
|
|
57
|
+
h ^= h >>> 16;
|
|
58
|
+
h = Math.imul(h, 0x85ebca6b);
|
|
59
|
+
h ^= h >>> 13;
|
|
60
|
+
h = Math.imul(h, 0xc2b2ae35);
|
|
61
|
+
h ^= h >>> 16;
|
|
62
|
+
return h >>> 0;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Rolling polynomial hash over `k`-token grams (32-bit, `Math.imul`), then
|
|
66
|
+
* robust winnowing: the rightmost minimum of every `w`-gram window, recorded
|
|
67
|
+
* once. Any shared run of `w + k - 1` tokens shares at least one fingerprint.
|
|
68
|
+
* `offsets` are gram start offsets into `kinds`.
|
|
69
|
+
*/
|
|
70
|
+
export function fingerprint(kinds, k = CLONE_GRAM, w = CLONE_WINDOW) {
|
|
71
|
+
const grams = kinds.length - k + 1;
|
|
72
|
+
if (grams < w || k < 1 || w < 1)
|
|
73
|
+
return { hashes: new Uint32Array(0), offsets: new Uint32Array(0) };
|
|
74
|
+
let top = 1;
|
|
75
|
+
for (let i = 1; i < k; i += 1)
|
|
76
|
+
top = Math.imul(top, HASH_BASE);
|
|
77
|
+
const gram = new Uint32Array(grams);
|
|
78
|
+
let h = 0;
|
|
79
|
+
for (let i = 0; i < k; i += 1)
|
|
80
|
+
h = (Math.imul(h, HASH_BASE) + (Number(kinds[i]) + 1)) >>> 0;
|
|
81
|
+
gram[0] = mix32(h);
|
|
82
|
+
for (let i = 1; i < grams; i += 1) {
|
|
83
|
+
const out = Math.imul(Number(kinds[i - 1]) + 1, top);
|
|
84
|
+
h = (Math.imul(h - out, HASH_BASE) + (Number(kinds[i + k - 1]) + 1)) >>> 0;
|
|
85
|
+
gram[i] = mix32(h);
|
|
86
|
+
}
|
|
87
|
+
const pickedHash = [];
|
|
88
|
+
const pickedAt = [];
|
|
89
|
+
let min = -1;
|
|
90
|
+
let last = -1;
|
|
91
|
+
for (let right = 0; right < grams; right += 1) {
|
|
92
|
+
const left = right - w + 1;
|
|
93
|
+
if (min < left || min === -1) {
|
|
94
|
+
min = right;
|
|
95
|
+
for (let j = right - 1; j >= Math.max(0, left); j -= 1) {
|
|
96
|
+
if (gram[j] < gram[min])
|
|
97
|
+
min = j;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (gram[right] <= gram[min]) {
|
|
101
|
+
min = right;
|
|
102
|
+
}
|
|
103
|
+
if (left >= 0 && min !== last) {
|
|
104
|
+
pickedHash.push(gram[min]);
|
|
105
|
+
pickedAt.push(min);
|
|
106
|
+
last = min;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { hashes: Uint32Array.from(pickedHash), offsets: Uint32Array.from(pickedAt) };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Sort every `(hash, file, offset)` by hash and pair files that share one.
|
|
113
|
+
* A run longer than `bucketCap` is boilerplate: skipped and counted. Output is
|
|
114
|
+
* grouped per file pair (`a < b`) and sorted, so it is deterministic.
|
|
115
|
+
*/
|
|
116
|
+
export function candidatePairs(table, options = {}) {
|
|
117
|
+
const bucketCap = options.bucketCap ?? CLONE_BUCKET_CAP;
|
|
118
|
+
const maxSeedPairs = options.maxSeedPairs ?? CLONE_MAX_SEED_PAIRS;
|
|
119
|
+
const { hashes, files, offsets } = table;
|
|
120
|
+
const order = new Uint32Array(table.count);
|
|
121
|
+
let maxFile = 0;
|
|
122
|
+
for (let i = 0; i < table.count; i += 1) {
|
|
123
|
+
order[i] = i;
|
|
124
|
+
if (files[i] > maxFile)
|
|
125
|
+
maxFile = files[i];
|
|
126
|
+
}
|
|
127
|
+
order.sort((x, y) => hashes[x] - hashes[y] ||
|
|
128
|
+
files[x] - files[y] ||
|
|
129
|
+
offsets[x] - offsets[y]);
|
|
130
|
+
const byPair = new Map();
|
|
131
|
+
let bucketsSkipped = 0;
|
|
132
|
+
let seedPairs = 0;
|
|
133
|
+
let truncated = false;
|
|
134
|
+
let start = 0;
|
|
135
|
+
while (start < order.length && !truncated) {
|
|
136
|
+
let end = start + 1;
|
|
137
|
+
const hash = hashes[order[start]];
|
|
138
|
+
while (end < order.length && hashes[order[end]] === hash)
|
|
139
|
+
end += 1;
|
|
140
|
+
if (end - start > bucketCap) {
|
|
141
|
+
bucketsSkipped += 1;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
for (let i = start; i < end && !truncated; i += 1) {
|
|
145
|
+
for (let j = i + 1; j < end; j += 1) {
|
|
146
|
+
const x = order[i];
|
|
147
|
+
const y = order[j];
|
|
148
|
+
const fx = files[x];
|
|
149
|
+
const fy = files[y];
|
|
150
|
+
if (fx === fy)
|
|
151
|
+
continue;
|
|
152
|
+
const [a, oa, b, ob] = fx < fy ? [fx, offsets[x], fy, offsets[y]] : [fy, offsets[y], fx, offsets[x]];
|
|
153
|
+
const key = a * (maxFile + 1) + b;
|
|
154
|
+
let row = byPair.get(key);
|
|
155
|
+
if (!row) {
|
|
156
|
+
row = { a, b, shared: 0, seeds: [] };
|
|
157
|
+
byPair.set(key, row);
|
|
158
|
+
}
|
|
159
|
+
row.shared += 1;
|
|
160
|
+
if (row.seeds.length < SEEDS_PER_FILE_PAIR * 2)
|
|
161
|
+
row.seeds.push(oa, ob);
|
|
162
|
+
seedPairs += 1;
|
|
163
|
+
if (seedPairs >= maxSeedPairs) {
|
|
164
|
+
truncated = true;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
start = end;
|
|
171
|
+
}
|
|
172
|
+
const pairs = [...byPair.values()].sort((left, right) => left.a - right.a || left.b - right.b);
|
|
173
|
+
return { pairs, bucketsSkipped, seedPairs, truncated };
|
|
174
|
+
}
|
|
175
|
+
/** Grow a seed left and right over equal tokens. `null` when shorter than `minTokens`. */
|
|
176
|
+
export function extendMatch(a, b, seedA, seedB, minTokens = CLONE_MIN_TOKENS) {
|
|
177
|
+
if (seedA < 0 || seedB < 0 || seedA >= a.length || seedB >= b.length)
|
|
178
|
+
return null;
|
|
179
|
+
let back = 0;
|
|
180
|
+
while (seedA - back - 1 >= 0 && seedB - back - 1 >= 0 && a[seedA - back - 1] === b[seedB - back - 1])
|
|
181
|
+
back += 1;
|
|
182
|
+
let ahead = 0;
|
|
183
|
+
while (seedA + ahead < a.length && seedB + ahead < b.length && a[seedA + ahead] === b[seedB + ahead])
|
|
184
|
+
ahead += 1;
|
|
185
|
+
const tokens = back + ahead;
|
|
186
|
+
if (tokens < minTokens)
|
|
187
|
+
return null;
|
|
188
|
+
return { aStart: seedA - back, aEnd: seedA + ahead, bStart: seedB - back, bEnd: seedB + ahead, tokens };
|
|
189
|
+
}
|
|
190
|
+
/** Identifiers that match position by position. Counts, not a ratio. */
|
|
191
|
+
export function sameNames(identsA, identsB) {
|
|
192
|
+
const total = Math.max(identsA.length, identsB.length);
|
|
193
|
+
let same = 0;
|
|
194
|
+
for (let i = 0; i < Math.min(identsA.length, identsB.length); i += 1) {
|
|
195
|
+
if (identsA[i] === identsB[i])
|
|
196
|
+
same += 1;
|
|
197
|
+
}
|
|
198
|
+
return { same, total };
|
|
199
|
+
}
|
|
200
|
+
/** A region with no identifiers carries no name evidence and is not kept. */
|
|
201
|
+
export function namesAgree(names) {
|
|
202
|
+
return names.total > 0 && names.same / names.total >= CLONE_NAME_AGREEMENT;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Map the gate's two-way decision for a file pair to a crossing. A universe
|
|
206
|
+
* wall that could not classify a path (`fail-closed`) is never a wall crossing.
|
|
207
|
+
* A parent ↔ child copy counts as same slice: the child may import the parent.
|
|
208
|
+
*/
|
|
209
|
+
export function classifyCrossing(input) {
|
|
210
|
+
if (!input.layerA || !input.layerB)
|
|
211
|
+
return 'unplaced';
|
|
212
|
+
const decisions = [input.forward, input.backward].filter((row) => Boolean(row));
|
|
213
|
+
const real = decisions.filter((row) => row.crossing !== 'fail-closed');
|
|
214
|
+
if (input.layerA !== input.layerB) {
|
|
215
|
+
const forward = input.forward && input.forward.crossing !== 'fail-closed';
|
|
216
|
+
const backward = input.backward && input.backward.crossing !== 'fail-closed';
|
|
217
|
+
return forward && backward ? 'cross-layer-walled' : 'cross-layer';
|
|
218
|
+
}
|
|
219
|
+
if (real.some((row) => row.crossing === 'cross-sibling'))
|
|
220
|
+
return 'cross-sibling';
|
|
221
|
+
const parent = real.find((row) => row.crossing === 'cross-parent');
|
|
222
|
+
if (parent)
|
|
223
|
+
return parent.childSlices ? 'cross-parent' : 'cross-slice';
|
|
224
|
+
if (decisions.length > real.length)
|
|
225
|
+
return 'fail-closed';
|
|
226
|
+
return 'same-slice';
|
|
227
|
+
}
|
|
228
|
+
/** Is this crossing one the section lists? */
|
|
229
|
+
export function isListedCrossing(outcome) {
|
|
230
|
+
return Object.prototype.hasOwnProperty.call(CROSSING_ORDER, outcome);
|
|
231
|
+
}
|
|
232
|
+
const GENERATED_HEADER = /GENERATED FILE|@generated|DO NOT EDIT|\bgenerated (?:from|by)\b/i;
|
|
233
|
+
/** First five lines carry a generated marker. */
|
|
234
|
+
export function isGeneratedHeader(text) {
|
|
235
|
+
const head = String(text).split('\n', 5).join('\n');
|
|
236
|
+
return GENERATED_HEADER.test(head);
|
|
237
|
+
}
|
|
238
|
+
function byText(left, right) {
|
|
239
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Union-find over verified pairs. Overlapping spans in one file are one member.
|
|
243
|
+
* Members and pairs come out sorted, so pair order never changes a family.
|
|
244
|
+
*/
|
|
245
|
+
export function groupFamilies(pairs) {
|
|
246
|
+
const spans = [];
|
|
247
|
+
for (const pair of pairs)
|
|
248
|
+
spans.push(pair.a, pair.b);
|
|
249
|
+
const order = spans.map((_, index) => index);
|
|
250
|
+
order.sort((x, y) => {
|
|
251
|
+
const left = spans[x];
|
|
252
|
+
const right = spans[y];
|
|
253
|
+
return byText(left.path, right.path) || left.start - right.start || left.end - right.end;
|
|
254
|
+
});
|
|
255
|
+
const memberOf = new Array(spans.length).fill(-1);
|
|
256
|
+
const members = [];
|
|
257
|
+
for (const index of order) {
|
|
258
|
+
const span = spans[index];
|
|
259
|
+
const current = members[members.length - 1];
|
|
260
|
+
if (current && current.path === span.path && span.start < current.end) {
|
|
261
|
+
current.end = Math.max(current.end, span.end);
|
|
262
|
+
current.startLine = Math.min(current.startLine, span.startLine);
|
|
263
|
+
current.endLine = Math.max(current.endLine, span.endLine);
|
|
264
|
+
current.tokens = current.end - current.start;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
members.push({ ...span, tokens: span.end - span.start });
|
|
268
|
+
}
|
|
269
|
+
memberOf[index] = members.length - 1;
|
|
270
|
+
}
|
|
271
|
+
const parent = members.map((_, index) => index);
|
|
272
|
+
const find = (x) => {
|
|
273
|
+
let root = x;
|
|
274
|
+
while (parent[root] !== root)
|
|
275
|
+
root = parent[root];
|
|
276
|
+
while (parent[x] !== root) {
|
|
277
|
+
const next = parent[x];
|
|
278
|
+
parent[x] = root;
|
|
279
|
+
x = next;
|
|
280
|
+
}
|
|
281
|
+
return root;
|
|
282
|
+
};
|
|
283
|
+
pairs.forEach((_, index) => {
|
|
284
|
+
const left = find(memberOf[index * 2]);
|
|
285
|
+
const right = find(memberOf[index * 2 + 1]);
|
|
286
|
+
if (left !== right)
|
|
287
|
+
parent[Math.max(left, right)] = Math.min(left, right);
|
|
288
|
+
});
|
|
289
|
+
const groups = new Map();
|
|
290
|
+
members.forEach((_, index) => {
|
|
291
|
+
const root = find(index);
|
|
292
|
+
groups.set(root, [...(groups.get(root) ?? []), index]);
|
|
293
|
+
});
|
|
294
|
+
const families = [];
|
|
295
|
+
for (const indexes of groups.values()) {
|
|
296
|
+
const local = new Map(indexes.map((memberIndex, position) => [memberIndex, position]));
|
|
297
|
+
const familyPairs = [];
|
|
298
|
+
const seen = new Set();
|
|
299
|
+
pairs.forEach((pair, index) => {
|
|
300
|
+
const a = local.get(memberOf[index * 2]);
|
|
301
|
+
const b = local.get(memberOf[index * 2 + 1]);
|
|
302
|
+
if (a === undefined || b === undefined || a === b)
|
|
303
|
+
return;
|
|
304
|
+
const [low, high] = a < b ? [a, b] : [b, a];
|
|
305
|
+
const key = `${low}:${high}`;
|
|
306
|
+
if (seen.has(key))
|
|
307
|
+
return;
|
|
308
|
+
seen.add(key);
|
|
309
|
+
familyPairs.push({ a: low, b: high, crossing: pair.crossing, names: { ...pair.names } });
|
|
310
|
+
});
|
|
311
|
+
familyPairs.sort((left, right) => left.a - right.a || left.b - right.b || CROSSING_ORDER[left.crossing] - CROSSING_ORDER[right.crossing]);
|
|
312
|
+
families.push({
|
|
313
|
+
members: indexes.map((memberIndex) => {
|
|
314
|
+
const member = members[memberIndex];
|
|
315
|
+
return {
|
|
316
|
+
path: member.path,
|
|
317
|
+
...(member.layer ? { layer: member.layer } : {}),
|
|
318
|
+
startLine: member.startLine,
|
|
319
|
+
endLine: member.endLine,
|
|
320
|
+
tokens: member.tokens,
|
|
321
|
+
};
|
|
322
|
+
}),
|
|
323
|
+
pairs: familyPairs,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return families.sort((left, right) => byText(left.members[0]?.path ?? '', right.members[0]?.path ?? ''));
|
|
327
|
+
}
|
|
328
|
+
function dirOf(rel) {
|
|
329
|
+
const at = rel.lastIndexOf('/');
|
|
330
|
+
return at === -1 ? '.' : rel.slice(0, at);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The pair that names a family: listing priority, then the best name agreement.
|
|
334
|
+
*/
|
|
335
|
+
export function primaryPair(family) {
|
|
336
|
+
return [...family.pairs].sort((left, right) => CROSSING_ORDER[left.crossing] - CROSSING_ORDER[right.crossing] ||
|
|
337
|
+
right.names.same * left.names.total - left.names.same * right.names.total ||
|
|
338
|
+
left.a - right.a ||
|
|
339
|
+
left.b - right.b)[0];
|
|
340
|
+
}
|
|
341
|
+
function destinationText(destination) {
|
|
342
|
+
switch (destination.kind) {
|
|
343
|
+
case 'shared-root':
|
|
344
|
+
case 'universe-common':
|
|
345
|
+
return `move it to ${destination.path} with /ark-place`;
|
|
346
|
+
case 'lower-layer':
|
|
347
|
+
return `keep it in ${destination.layer}, the layer both sides may import, with /ark-place`;
|
|
348
|
+
case 'shared-layer':
|
|
349
|
+
return `move it to ${destination.layer}, a layer both sides may import, with /ark-place`;
|
|
350
|
+
case 'either-layer':
|
|
351
|
+
return `keep one copy in ${(destination.layers ?? []).join(' or ')} and import it from the other, with /ark-place`;
|
|
352
|
+
default:
|
|
353
|
+
return 'find a shared home your config lets both sides import with /ark-place';
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** One human line per family. Shared by the terminal and the report. */
|
|
357
|
+
export function cloneFamilyLine(family) {
|
|
358
|
+
const pair = primaryPair(family);
|
|
359
|
+
const left = family.members[pair?.a ?? 0];
|
|
360
|
+
const right = family.members[pair?.b ?? 1];
|
|
361
|
+
const more = family.members.length > 2 ? ` (+${family.members.length - 2} more)` : '';
|
|
362
|
+
const names = `${family.names.same} of ${family.names.total} names match`;
|
|
363
|
+
const next = destinationText(family.destination);
|
|
364
|
+
if (family.crossing === 'cross-layer' || family.crossing === 'cross-layer-walled') {
|
|
365
|
+
const wall = family.crossing === 'cross-layer-walled' ? ' Neither layer may import the other.' : '';
|
|
366
|
+
return `This code is copied between the ${left?.layer ?? '?'} and ${right?.layer ?? '?'} layers${more} (${names}).${wall} Next: ${next}.`;
|
|
367
|
+
}
|
|
368
|
+
return `This code is copied between ${dirOf(left?.path ?? '')} and ${dirOf(right?.path ?? '')}${more} (${names}). The wall stops the import, not the copy. Next: ${next}.`;
|
|
369
|
+
}
|
|
370
|
+
function method() {
|
|
371
|
+
return {
|
|
372
|
+
gram: CLONE_GRAM,
|
|
373
|
+
window: CLONE_WINDOW,
|
|
374
|
+
minTokens: CLONE_MIN_TOKENS,
|
|
375
|
+
minLines: CLONE_MIN_LINES,
|
|
376
|
+
nameAgreement: CLONE_NAME_AGREEMENT,
|
|
377
|
+
bucketCap: CLONE_BUCKET_CAP,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/** All-zero totals, for `not-run` / `unavailable`. */
|
|
381
|
+
export function emptyDuplicationTotals() {
|
|
382
|
+
return {
|
|
383
|
+
filesEligible: 0,
|
|
384
|
+
filesFingerprinted: 0,
|
|
385
|
+
filesSkipped: {},
|
|
386
|
+
fingerprints: 0,
|
|
387
|
+
seedPairs: 0,
|
|
388
|
+
bucketsSkipped: 0,
|
|
389
|
+
candidateFilePairs: 0,
|
|
390
|
+
pairsCrossBoundary: 0,
|
|
391
|
+
pairsVerified: 0,
|
|
392
|
+
pairsSameSliceUnexamined: 0,
|
|
393
|
+
pairsUnclassifiable: 0,
|
|
394
|
+
pairsUnplaced: 0,
|
|
395
|
+
clonePairs: 0,
|
|
396
|
+
families: 0,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function shell(status, headline, honesty) {
|
|
400
|
+
return {
|
|
401
|
+
schemaVersion: ARK_CLONE_DETECTION_SCHEMA_VERSION,
|
|
402
|
+
advisory: true,
|
|
403
|
+
notAScore: true,
|
|
404
|
+
status,
|
|
405
|
+
headline,
|
|
406
|
+
families: [],
|
|
407
|
+
truncated: 0,
|
|
408
|
+
totals: emptyDuplicationTotals(),
|
|
409
|
+
method: method(),
|
|
410
|
+
honesty,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/** Compact status and scoped runs: the JSON names the command that runs it. */
|
|
414
|
+
export function notRunDuplication(reason) {
|
|
415
|
+
return {
|
|
416
|
+
...shell('not-run', 'Copies across a wall are checked in status details.', []),
|
|
417
|
+
next: DUPLICATION_COMMAND,
|
|
418
|
+
...(reason ? { reason } : {}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
export function unavailableDuplication(note) {
|
|
422
|
+
return shell('unavailable', 'Copies across a wall could not be checked.', [note]);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Final section. Families are listed walled first, then by size, then by path,
|
|
426
|
+
* capped with an honest `truncated`. Any partial reason makes the status `partial`.
|
|
427
|
+
*/
|
|
428
|
+
export function buildDuplicationAdvisory(input) {
|
|
429
|
+
const families = [];
|
|
430
|
+
for (const draft of input.families) {
|
|
431
|
+
const pair = primaryPair(draft);
|
|
432
|
+
if (!pair)
|
|
433
|
+
continue;
|
|
434
|
+
const walled = pair.crossing !== 'cross-layer';
|
|
435
|
+
const family = {
|
|
436
|
+
ruleId: walled ? 'CROSS_WALL_DUPLICATE' : 'CROSS_LAYER_DUPLICATE',
|
|
437
|
+
crossing: pair.crossing,
|
|
438
|
+
names: { ...pair.names },
|
|
439
|
+
members: draft.members.map((member) => ({ ...member })),
|
|
440
|
+
pairs: draft.pairs.map((row) => ({ ...row, names: { ...row.names } })),
|
|
441
|
+
destination: {
|
|
442
|
+
...draft.destination,
|
|
443
|
+
...(draft.destination.layers ? { layers: [...draft.destination.layers] } : {}),
|
|
444
|
+
},
|
|
445
|
+
line: '',
|
|
446
|
+
};
|
|
447
|
+
family.line = cloneFamilyLine(family);
|
|
448
|
+
families.push(family);
|
|
449
|
+
}
|
|
450
|
+
const size = (family) => Math.max(0, ...family.members.map((member) => member.tokens));
|
|
451
|
+
families.sort((left, right) => CROSSING_ORDER[left.crossing] - CROSSING_ORDER[right.crossing] ||
|
|
452
|
+
size(right) - size(left) ||
|
|
453
|
+
byText(left.members[0]?.path ?? '', right.members[0]?.path ?? ''));
|
|
454
|
+
const listed = families.slice(0, CLONE_FAMILY_LIST_CAP);
|
|
455
|
+
const partial = input.partialReasons.length > 0;
|
|
456
|
+
const count = families.length;
|
|
457
|
+
const headline = count === 0
|
|
458
|
+
? partial
|
|
459
|
+
? 'No copy across a wall or a layer was found in the files checked.'
|
|
460
|
+
: 'No copy crosses a wall or a layer.'
|
|
461
|
+
: `${count} cop${count === 1 ? 'y crosses' : 'ies cross'} a wall or a layer.`;
|
|
462
|
+
return {
|
|
463
|
+
schemaVersion: ARK_CLONE_DETECTION_SCHEMA_VERSION,
|
|
464
|
+
advisory: true,
|
|
465
|
+
notAScore: true,
|
|
466
|
+
status: partial ? 'partial' : 'complete',
|
|
467
|
+
headline,
|
|
468
|
+
families: listed,
|
|
469
|
+
truncated: count - listed.length,
|
|
470
|
+
totals: { ...input.totals, filesSkipped: { ...input.totals.filesSkipped }, families: count },
|
|
471
|
+
method: method(),
|
|
472
|
+
honesty: [...input.partialReasons, ...(input.notes ?? [])],
|
|
473
|
+
};
|
|
474
|
+
}
|
package/bin/lib/codex-home.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { execCommandParts } from '../ark-shared.mjs';
|
|
10
10
|
import { npxArkgatePrefixLength } from './package-manager.mjs';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const PREFERRED_CODEX_MCP_BIN = 'arkgate-mcp';
|
|
13
13
|
|
|
14
14
|
/** Where Codex loads slash-command prompts ($CODEX_HOME/prompts) — legacy, not the skill catalog. */
|
|
15
15
|
export function codexPromptsDir() {
|
|
@@ -66,10 +66,15 @@ export const CHILD_SLICES_SCHEMA_DEF = {
|
|
|
66
66
|
minLength: 1,
|
|
67
67
|
description: 'Text for inner-wall findings (CROSS_SIBLING_SLICE and universe common importing a child). The rule message stays the universe-wall text (CROSS_PARENT_SLICE and fail-closed denies). Absent: ArkGate default text, never the rule message. arkgate 4.8.23 and older reject this field (unknown field).',
|
|
68
68
|
},
|
|
69
|
+
arkRulesFile: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
minLength: 1,
|
|
72
|
+
description: 'Filename read at each child slice root this wall already resolved. The only token is <Layer>, replaced with a declared layer name (arkrules.<Layer>.json). No slash and no wildcard. A discovered file that omits appliesTo is scoped to that slice directory. Explicit appliesTo outside the slice fails config load (ARKRULE_SCOPE_ESCAPES_SLICE). The effective id is <childId>#<localId>. A build that rejects unknown childSlices fields fails at config load (unknown field).',
|
|
73
|
+
},
|
|
69
74
|
sliceAliases: {
|
|
70
75
|
type: 'array',
|
|
71
76
|
minItems: 1,
|
|
72
|
-
description: 'Maps a source path glob onto a child slice id (universe id plus one child segment) so files outside the slice trees take that universe and child for both walls. A path without a wildcard also covers everything under it, as sharedRoots does. A bare name, a universe id alone, a target outside every universe shape, and a wildcard in to are rejected. Under stars identity a target written against the 4.8.23 stars id (star bindings before the last literal dropped, e.g. api/v1/x for modules/*/api/*) still loads when it maps to one universe shape, and ark-check warns CONFIG_SLICE_LEGACY_STARS_ID with the new id; it is rejected only when it maps to more than one shape. Config load checks only the shape; doctor reports a target universe no file belongs to. The glob may not overlap a slice folder, and two aliases may not match the same file. Doctor lists each alias as an owed move. A build that rejects unknown childSlices fields fails at config load (unknown field). arkgate 4.8.22 and older reject childSlices.',
|
|
77
|
+
description: 'Maps a source path glob onto a child slice id (universe id plus one child segment) so files outside the slice trees take that universe and child for both walls. A path without a wildcard also covers everything under it, as sharedRoots does. A bare name, a universe id alone, a target outside every universe shape, and a wildcard in to are rejected. Under stars identity a target written against the 4.8.23 stars id (star bindings before the last literal dropped, e.g. api/v1/x for modules/*/api/*) still loads when it maps to one universe shape, and ark-check warns CONFIG_SLICE_LEGACY_STARS_ID with the new id; it is rejected only when it maps to more than one shape. Config load checks only the shape; doctor reports a target universe no file belongs to. The glob may not overlap a slice folder, and two aliases may not match the same file. Doctor lists each unpinned alias as an owed move. pinned true lists the alias separately and does not count it as debt. reason is a doctor label and does not pin by itself. A build that rejects unknown childSlices fields fails at config load (unknown field). arkgate 4.8.22 and older reject childSlices.',
|
|
73
78
|
items: {
|
|
74
79
|
type: 'object',
|
|
75
80
|
additionalProperties: false,
|
|
@@ -77,6 +82,15 @@ export const CHILD_SLICES_SCHEMA_DEF = {
|
|
|
77
82
|
properties: {
|
|
78
83
|
from: { type: 'string', minLength: 1 },
|
|
79
84
|
to: { type: 'string', minLength: 1 },
|
|
85
|
+
pinned: {
|
|
86
|
+
type: 'boolean',
|
|
87
|
+
description: 'True when a framework forces the path to stay. The alias is not an owed move and does not clear other honesty debt.',
|
|
88
|
+
},
|
|
89
|
+
reason: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
minLength: 1,
|
|
92
|
+
description: 'Doctor label, such as framework-route. A label alone does not pin.',
|
|
93
|
+
},
|
|
80
94
|
},
|
|
81
95
|
},
|
|
82
96
|
},
|
|
@@ -117,6 +131,7 @@ export function validateSliceContract(candidate, issues) {
|
|
|
117
131
|
validateChildSliceSiblings(candidate, issues);
|
|
118
132
|
validateChildSliceAllowedCrossSlice(candidate, issues);
|
|
119
133
|
validateChildSliceAliases(candidate, issues);
|
|
134
|
+
validateChildSliceArkRulesFile(candidate, issues);
|
|
120
135
|
}
|
|
121
136
|
// childSlices on a rule without peerIsolation: true + allowed: false is inert. 4.8.23
|
|
122
137
|
// loaded it, so config load keeps accepting it; ark-check warns CONFIG_CHILD_SLICES_INERT
|
|
@@ -192,7 +207,7 @@ function stopAtEntryIssue(raw, layerFolders) {
|
|
|
192
207
|
return null;
|
|
193
208
|
}
|
|
194
209
|
/** String form unchanged. Object form carries composition roots the walk stops at. */
|
|
195
|
-
|
|
210
|
+
function validateSharedImportsSlice(candidate, issues) {
|
|
196
211
|
const rules = candidate.rules;
|
|
197
212
|
if (!Array.isArray(rules))
|
|
198
213
|
return;
|
|
@@ -262,7 +277,7 @@ const SIBLING_MODES = ['deny', 'advisory'];
|
|
|
262
277
|
* The message names both forms so a bad value is obvious at config load.
|
|
263
278
|
*/
|
|
264
279
|
const SIBLINGS_FORM_MESSAGE = 'must be "deny", "advisory", or { "default": "deny" | "advisory", "enforce": ["<child id or subtree path>"], "ratchet": true | false }';
|
|
265
|
-
|
|
280
|
+
function validateChildSliceSiblings(candidate, issues) {
|
|
266
281
|
const rules = candidate.rules;
|
|
267
282
|
if (!Array.isArray(rules))
|
|
268
283
|
return;
|
|
@@ -359,7 +374,7 @@ function childCrossSlicePatternIssue(raw) {
|
|
|
359
374
|
function childCrossSlicePatternKey(raw) {
|
|
360
375
|
return trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase();
|
|
361
376
|
}
|
|
362
|
-
|
|
377
|
+
function validateChildSliceAllowedCrossSlice(candidate, issues) {
|
|
363
378
|
const rules = candidate.rules;
|
|
364
379
|
if (!Array.isArray(rules))
|
|
365
380
|
return;
|
|
@@ -632,7 +647,30 @@ function globsCanMatchSame(left, right, i, j, memo) {
|
|
|
632
647
|
memo.set(key, matched);
|
|
633
648
|
return matched;
|
|
634
649
|
}
|
|
635
|
-
|
|
650
|
+
const ARK_RULES_FILE_MESSAGE = 'must be a filename with exactly one <Layer> token and no slash or wildcard (arkrules.<Layer>.json)';
|
|
651
|
+
function validateChildSliceArkRulesFile(candidate, issues) {
|
|
652
|
+
const rules = candidate.rules;
|
|
653
|
+
if (!Array.isArray(rules))
|
|
654
|
+
return;
|
|
655
|
+
rules.forEach((rule, index) => {
|
|
656
|
+
if (!isObject(rule))
|
|
657
|
+
return;
|
|
658
|
+
const child = rule.childSlices;
|
|
659
|
+
if (!isObject(child) || child.arkRulesFile === undefined)
|
|
660
|
+
return;
|
|
661
|
+
const path = `$.rules[${index}].childSlices.arkRulesFile`;
|
|
662
|
+
const pattern = child.arkRulesFile;
|
|
663
|
+
if (typeof pattern !== 'string' || pattern.length === 0) {
|
|
664
|
+
issues.push({ path, message: ARK_RULES_FILE_MESSAGE });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const parts = pattern.split('<Layer>');
|
|
668
|
+
if (parts.length !== 2 || /[*\\/]/.test(pattern) || pattern.includes('..')) {
|
|
669
|
+
issues.push({ path, message: ARK_RULES_FILE_MESSAGE });
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
function validateChildSliceAliases(candidate, issues) {
|
|
636
674
|
const rules = candidate.rules;
|
|
637
675
|
if (!Array.isArray(rules))
|
|
638
676
|
return;
|
|
@@ -661,7 +699,7 @@ export function validateChildSliceAliases(candidate, issues) {
|
|
|
661
699
|
return;
|
|
662
700
|
}
|
|
663
701
|
for (const key of Object.keys(alias)) {
|
|
664
|
-
if (key !== 'from' && key !== 'to') {
|
|
702
|
+
if (key !== 'from' && key !== 'to' && key !== 'pinned' && key !== 'reason') {
|
|
665
703
|
issues.push({ path: propertyPath(aliasPath, key), message: 'unknown field' });
|
|
666
704
|
}
|
|
667
705
|
}
|