arkgate 4.8.23 → 4.8.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +153 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +41 -91
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +707 -121
- package/bin/ark-mcp-runtime.mjs +448 -169
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +42 -22
- package/bin/ark.mjs +65 -97
- package/bin/lib/adapter-contract-types.mjs +5 -0
- package/bin/lib/adapter-contract.mjs +15 -0
- 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 +9 -5
- package/bin/lib/ambient-state.mjs +9 -30
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +61 -15
- package/bin/lib/ark-order-doctor.mjs +4 -29
- package/bin/lib/ark-order-facts.mjs +319 -17
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +3 -2
- package/bin/lib/ark-run-doctor.mjs +17 -28
- package/bin/lib/ark-run-facts.mjs +461 -48
- package/bin/lib/ark-run-sensors.mjs +6 -4
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrule-file-hints.mjs +2 -47
- package/bin/lib/arkrules-contract.mjs +124 -133
- package/bin/lib/arkrules-sensors.mjs +55 -252
- package/bin/lib/check-args.mjs +85 -1
- package/bin/lib/ci-and-commands.mjs +24 -78
- package/bin/lib/ci-merge-boundary.mjs +1 -1
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/clone-detection.mjs +474 -0
- package/bin/lib/codex-home.mjs +5 -3
- package/bin/lib/config-contract-slices.mjs +739 -0
- package/bin/lib/config-contract.mjs +42 -607
- package/bin/lib/config-extras.mjs +16 -22
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/contract-smells.mjs +33 -15
- package/bin/lib/core-ratchet.mjs +0 -2
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/deep-module-coach.mjs +2 -4
- package/bin/lib/deploy-path.mjs +13 -38
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/design-smells.mjs +2 -2
- package/bin/lib/diagnostic-catalog.mjs +23 -9
- package/bin/lib/doctor-advisories.mjs +78 -8
- package/bin/lib/doctor-green-cite.mjs +1 -1
- package/bin/lib/doctor-human.mjs +1 -1
- package/bin/lib/doctor-next-actions.mjs +19 -4
- package/bin/lib/doctor-plan.mjs +9 -13
- package/bin/lib/duplication-io.mjs +572 -0
- package/bin/lib/effective-contract-load.mjs +282 -83
- package/bin/lib/enforcement-profiles.mjs +40 -4
- 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 +10 -5
- package/bin/lib/flat-parent-importers.mjs +139 -0
- package/bin/lib/flat-parent-pilot.mjs +40 -100
- package/bin/lib/gate-files.mjs +58 -90
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +166 -29
- package/bin/lib/golden-pattern.mjs +1 -1
- package/bin/lib/hook-templates.mjs +17 -15
- package/bin/lib/host-support-matrix.mjs +3 -1
- 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/import-resolve.mjs +13 -1
- package/bin/lib/improvement-compass-doctor.mjs +1 -24
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage-io.mjs +4 -3
- package/bin/lib/invariant-coverage.mjs +85 -11
- 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 +13 -6
- package/bin/lib/mcp-hook-payload.mjs +403 -63
- 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 +60 -2
- package/bin/lib/parse-health.mjs +1 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +106 -0
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +219 -14
- package/bin/lib/post-green-path.mjs +1 -1
- package/bin/lib/prepare-change.mjs +115 -7
- package/bin/lib/prepare-write.mjs +2 -2
- package/bin/lib/presets.mjs +59 -73
- package/bin/lib/product-copy.mjs +1 -6
- package/bin/lib/project-root.mjs +31 -62
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +405 -117
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +247 -9
- package/bin/lib/scan-files.mjs +36 -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 +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/start-install-recovery.mjs +72 -2
- package/bin/lib/start-preview.mjs +31 -1
- package/bin/lib/states-transitions-presence.mjs +3 -3
- package/bin/lib/status-command.mjs +35 -22
- package/bin/lib/status-manifest.mjs +50 -30
- package/bin/lib/status-transition-catalog.mjs +0 -6
- package/bin/lib/suggestions.mjs +3 -3
- package/bin/lib/team-parliament-io.mjs +76 -88
- package/bin/lib/team-parliament.mjs +21 -10
- 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 +6 -4
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-AHACEKSV.js +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/chunk-UN5EH4QV.cjs +16 -0
- package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
- package/dist/configTypes-GPvvdleM.d.ts +257 -0
- package/dist/eslint/index.cjs +18 -5
- package/dist/eslint/index.d.cts +368 -0
- package/dist/eslint/index.d.ts +139 -156
- package/dist/eslint/index.js +11 -5
- package/dist/index.cjs +36 -35
- package/dist/index.d.cts +5547 -0
- package/dist/index.d.ts +3283 -198
- package/dist/index.js +36 -35
- package/dist/inspectorListen-6DQVJ4DC.js +1 -0
- package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
- package/dist/nestjs/index.cjs +1 -18
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +1 -3
- package/dist/nestjs/index.js +1 -18
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.cts +155 -0
- package/dist/order/index.d.ts +15 -3
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +13 -30
- package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
- package/dist/runtime/index.d.ts +2741 -133
- package/dist/runtime/index.js +13 -30
- package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
- package/dist/types-CnauMQQL.d.ts +1800 -0
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +217 -16
- package/docs/ai-gates.md +174 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +230 -31
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +212 -19
- package/docs/package-surface.md +35 -9
- package/docs/product-voice.md +7 -1
- package/package.json +69 -19
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +77 -12
- package/schemas/ark.invariant-probe.schema.json +368 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/schemas/ark.status-manifest.schema.json +22 -0
- package/server.json +3 -9
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
- 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-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-place/SKILL.md +8 -0
- package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
- package/templates/arkrules/PersistenceAdapters.json +1 -1
- package/templates/arkrules/PresentationAdapters.json +1 -1
- package/templates/skills/ark-adopt.md +5 -1
- 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-order.md +4 -2
- package/templates/skills/ark-place.md +8 -0
- package/templates/skills/ark-runtime.md +6 -1
- package/bin/lib/ark-order-error.mjs +0 -18
- package/bin/lib/ark-order-invariants.mjs +0 -346
- package/dist/types-Ik_OakcI.d.ts +0 -183
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importer graph for the flat-parent doctor suggestion.
|
|
3
|
+
*
|
|
4
|
+
* Prefers the importer index the scan projected from resolved facts (ADR 0037),
|
|
5
|
+
* then raw resolved dependency facts (tsconfig paths included). Without either it scans the text and resolves relative
|
|
6
|
+
* specifiers plus tsconfig path aliases, so an `@/…` importer is never
|
|
7
|
+
* invisible. Advisory input only.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { importerGraphFromIndex } from './import-graph-projection.mjs';
|
|
12
|
+
import { readTsconfigAliases, resolveSpecifierToRel } from './import-resolve.mjs';
|
|
13
|
+
|
|
14
|
+
const SPEC_MARKS = [' from "', " from '", ' from `', 'require("', "require('", 'import("', "import('"];
|
|
15
|
+
const EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
|
|
16
|
+
|
|
17
|
+
function withoutComments(text) {
|
|
18
|
+
let out = '';
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < text.length) {
|
|
21
|
+
const c = text[i];
|
|
22
|
+
const n = text[i + 1];
|
|
23
|
+
if (c === '/' && n === '/') {
|
|
24
|
+
while (i < text.length && text[i] !== '\n') i += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (c === '/' && n === '*') {
|
|
28
|
+
i += 2;
|
|
29
|
+
while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
|
|
30
|
+
i = Math.min(text.length, i + 2);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
out += c;
|
|
34
|
+
i += 1;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every string module specifier in `text` (relative and bare). */
|
|
40
|
+
function moduleSpecifiers(text) {
|
|
41
|
+
const specs = [];
|
|
42
|
+
const source = withoutComments(String(text));
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < source.length) {
|
|
45
|
+
let at = -1;
|
|
46
|
+
let mark = '';
|
|
47
|
+
for (const candidate of SPEC_MARKS) {
|
|
48
|
+
const found = source.indexOf(candidate, i);
|
|
49
|
+
if (found !== -1 && (at === -1 || found < at)) {
|
|
50
|
+
at = found;
|
|
51
|
+
mark = candidate;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (at === -1) break;
|
|
55
|
+
const quote = mark[mark.length - 1];
|
|
56
|
+
const start = at + mark.length;
|
|
57
|
+
const end = source.indexOf(quote, start);
|
|
58
|
+
if (end === -1) break;
|
|
59
|
+
const spec = source.slice(start, end);
|
|
60
|
+
if (spec.length > 0) specs.push(spec);
|
|
61
|
+
i = end + 1;
|
|
62
|
+
}
|
|
63
|
+
return specs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveRelative(fromFile, spec) {
|
|
67
|
+
const base = fromFile.split('/');
|
|
68
|
+
base.pop();
|
|
69
|
+
for (const part of spec.split('/')) {
|
|
70
|
+
if (part === '' || part === '.') continue;
|
|
71
|
+
if (part === '..') {
|
|
72
|
+
if (base.length === 0) return null;
|
|
73
|
+
base.pop();
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
base.push(part);
|
|
77
|
+
}
|
|
78
|
+
return base.join('/');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function probe(joined, fileSet) {
|
|
82
|
+
if (!joined) return null;
|
|
83
|
+
const names = [joined];
|
|
84
|
+
for (const ext of EXT) names.push(`${joined}${ext}`, `${joined}/index${ext}`);
|
|
85
|
+
const stem = joined.replace(/\.(?:m|c)?js$/, '');
|
|
86
|
+
if (stem !== joined) for (const ext of EXT) names.push(`${stem}${ext}`);
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
if (fileSet.has(name)) return name;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readText(root, rel) {
|
|
94
|
+
try {
|
|
95
|
+
return fs.readFileSync(path.join(root, rel), 'utf8');
|
|
96
|
+
} catch {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function addEdge(graph, target, importer) {
|
|
102
|
+
let set = graph.get(target);
|
|
103
|
+
if (!set) {
|
|
104
|
+
set = new Set();
|
|
105
|
+
graph.set(target, set);
|
|
106
|
+
}
|
|
107
|
+
set.add(importer);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* target file → every file that imports it (value and type-only edges; a
|
|
112
|
+
* peerIsolation wall judges both).
|
|
113
|
+
* @param {{ root: string, files: string[], facts?: object, ts?: object }} input
|
|
114
|
+
*/
|
|
115
|
+
export function flatParentImporterGraph({ root, files, facts, ts }) {
|
|
116
|
+
if (facts?.importGraph) return importerGraphFromIndex(facts.importGraph);
|
|
117
|
+
const graph = new Map();
|
|
118
|
+
const fileSet = new Set(files);
|
|
119
|
+
const dependencies = Array.isArray(facts?.dependencies) ? facts.dependencies : null;
|
|
120
|
+
if (dependencies && dependencies.length > 0) {
|
|
121
|
+
for (const dep of dependencies) {
|
|
122
|
+
if (dep?.resolution !== 'resolved-project' || typeof dep.target !== 'string') continue;
|
|
123
|
+
if (typeof dep.from !== 'string' || dep.from === dep.target) continue;
|
|
124
|
+
addEdge(graph, dep.target, dep.from);
|
|
125
|
+
}
|
|
126
|
+
return graph;
|
|
127
|
+
}
|
|
128
|
+
const aliases = readTsconfigAliases(ts, root);
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
for (const spec of moduleSpecifiers(readText(root, file))) {
|
|
131
|
+
const joined = spec.startsWith('.')
|
|
132
|
+
? resolveRelative(file, spec)
|
|
133
|
+
: resolveSpecifierToRel(spec, file, root, aliases);
|
|
134
|
+
const target = probe(joined, fileSet);
|
|
135
|
+
if (target && target !== file) addEdge(graph, target, file);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return graph;
|
|
139
|
+
}
|
|
@@ -2,18 +2,15 @@
|
|
|
2
2
|
* Doctor suggestion: a universe-level flat file that one child slice imports.
|
|
3
3
|
* Advisory only. Not a finding, not a verdict, not a config field.
|
|
4
4
|
*/
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
5
|
import {
|
|
6
|
+
findDeniedEdgeDecision,
|
|
8
7
|
layerForRelativePath,
|
|
9
8
|
resolveGovernedSlice,
|
|
10
9
|
sliceAliasDestination,
|
|
11
10
|
sliceIdForPath,
|
|
12
11
|
} from '../ark-layer-match.mjs';
|
|
13
12
|
import { destinationKeepsLayerAndSlice } from './physical-cohesion.mjs';
|
|
14
|
-
|
|
15
|
-
const SPEC_MARKS = [' from "', " from '", ' from `', 'require("', "require('", 'import("', "import('"];
|
|
16
|
-
const EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
|
|
13
|
+
import { flatParentImporterGraph } from './flat-parent-importers.mjs';
|
|
17
14
|
|
|
18
15
|
function ruleKey(rule) {
|
|
19
16
|
const child = rule?.childSlices;
|
|
@@ -45,102 +42,41 @@ function isFlatParent(rel, rule) {
|
|
|
45
42
|
return Boolean(universe && raw && raw.toLowerCase() === universe.toLowerCase());
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Importers of each flat file, split by child. A file any non-child imports
|
|
47
|
+
* (universe common, a shared root, another universe, another layer) is marked:
|
|
48
|
+
* moving it into one child would turn that edge into a wall deny.
|
|
49
|
+
*/
|
|
50
|
+
function childImporters(flatFiles, graph, rule) {
|
|
51
|
+
const out = new Map();
|
|
52
|
+
for (const target of flatFiles) {
|
|
53
|
+
const universe = resolveGovernedSlice(target, rule).universeId ?? '';
|
|
54
|
+
const ids = new Set();
|
|
55
|
+
let nonChild = false;
|
|
56
|
+
for (const importer of graph.get(target) ?? []) {
|
|
57
|
+
const place = resolveGovernedSlice(importer, rule);
|
|
58
|
+
if (!place.childId || (place.universeId ?? '') !== universe) nonChild = true;
|
|
59
|
+
else ids.add(place.childId);
|
|
63
60
|
}
|
|
64
|
-
out
|
|
65
|
-
i += 1;
|
|
61
|
+
out.set(target, { ids, nonChild, importers: [...(graph.get(target) ?? [])] });
|
|
66
62
|
}
|
|
67
63
|
return out;
|
|
68
64
|
}
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if (at === -1) break;
|
|
85
|
-
const quote = mark[mark.length - 1];
|
|
86
|
-
const start = at + mark.length;
|
|
87
|
-
const end = source.indexOf(quote, start);
|
|
88
|
-
if (end === -1) break;
|
|
89
|
-
const spec = source.slice(start, end);
|
|
90
|
-
if (spec.startsWith('.')) specs.push(spec);
|
|
91
|
-
i = end + 1;
|
|
92
|
-
}
|
|
93
|
-
return specs;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function resolveRelative(fromFile, spec, fileSet) {
|
|
97
|
-
const base = fromFile.split('/');
|
|
98
|
-
base.pop();
|
|
99
|
-
for (const part of spec.split('/')) {
|
|
100
|
-
if (part === '' || part === '.') continue;
|
|
101
|
-
if (part === '..') {
|
|
102
|
-
if (base.length === 0) return null;
|
|
103
|
-
base.pop();
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
base.push(part);
|
|
107
|
-
}
|
|
108
|
-
const joined = base.join('/');
|
|
109
|
-
const names = [joined];
|
|
110
|
-
for (const ext of EXT) names.push(`${joined}${ext}`, `${joined}/index${ext}`);
|
|
111
|
-
for (const name of names) {
|
|
112
|
-
if (fileSet.has(name)) return name;
|
|
113
|
-
}
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function readText(root, rel) {
|
|
118
|
-
try {
|
|
119
|
-
return fs.readFileSync(path.join(root, rel), 'utf8');
|
|
120
|
-
} catch {
|
|
121
|
-
return '';
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function childImporters(flatFiles, files, rule, root) {
|
|
126
|
-
const flat = new Set(flatFiles);
|
|
127
|
-
const known = new Set(files);
|
|
128
|
-
const importers = new Map();
|
|
129
|
-
for (const file of files) {
|
|
130
|
-
const place = resolveGovernedSlice(file, rule);
|
|
131
|
-
if (!place.childId) continue;
|
|
132
|
-
for (const spec of relativeSpecifiers(readText(root, file))) {
|
|
133
|
-
const target = resolveRelative(file, spec, known);
|
|
134
|
-
if (!target || !flat.has(target) || target === file) continue;
|
|
135
|
-
let ids = importers.get(target);
|
|
136
|
-
if (!ids) {
|
|
137
|
-
ids = new Set();
|
|
138
|
-
importers.set(target, ids);
|
|
139
|
-
}
|
|
140
|
-
ids.add(place.childId);
|
|
141
|
-
}
|
|
66
|
+
/** Would any recorded importer be denied by the rules after the move? */
|
|
67
|
+
function moveBreaksWall(importers, destFile, rules, layers) {
|
|
68
|
+
const toLayer = layerForRelativePath(destFile, layers);
|
|
69
|
+
for (const importer of importers) {
|
|
70
|
+
const fromLayer = layerForRelativePath(importer, layers);
|
|
71
|
+
if (!fromLayer || !toLayer) return true;
|
|
72
|
+
const denied = findDeniedEdgeDecision(rules, fromLayer, toLayer, {
|
|
73
|
+
fromPath: importer,
|
|
74
|
+
toPath: destFile,
|
|
75
|
+
layers,
|
|
76
|
+
});
|
|
77
|
+
if (denied) return true;
|
|
142
78
|
}
|
|
143
|
-
return
|
|
79
|
+
return false;
|
|
144
80
|
}
|
|
145
81
|
|
|
146
82
|
function basename(rel) {
|
|
@@ -149,7 +85,7 @@ function basename(rel) {
|
|
|
149
85
|
}
|
|
150
86
|
|
|
151
87
|
/**
|
|
152
|
-
* @param {{ root: string, files: string[], rules: object[] | undefined, layers: object[] | undefined }} input
|
|
88
|
+
* @param {{ root: string, files: string[], rules: object[] | undefined, layers: object[] | undefined, facts?: object, ts?: object }} input
|
|
153
89
|
* @returns {object | null}
|
|
154
90
|
*/
|
|
155
91
|
export function collectFlatParentPilot(input) {
|
|
@@ -161,15 +97,18 @@ export function collectFlatParentPilot(input) {
|
|
|
161
97
|
const layers = input.layers;
|
|
162
98
|
const moves = [];
|
|
163
99
|
const seen = new Set();
|
|
100
|
+
let graph = null;
|
|
164
101
|
for (const rule of rules) {
|
|
165
102
|
const flat = files.filter((file) => isFlatParent(file, rule));
|
|
166
103
|
if (flat.length === 0) continue;
|
|
167
|
-
|
|
104
|
+
graph ??= flatParentImporterGraph({ root: input.root, files, facts: input.facts, ts: input.ts });
|
|
105
|
+
const importers = childImporters(flat, graph, rule);
|
|
168
106
|
for (const file of flat) {
|
|
169
107
|
if (seen.has(file)) continue;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
108
|
+
const entry = importers.get(file);
|
|
109
|
+
// Exactly one child and nobody else: two children, or any non-child importer, keeps it in place.
|
|
110
|
+
if (!entry || entry.nonChild || entry.ids.size !== 1) continue;
|
|
111
|
+
const importer = [...entry.ids][0];
|
|
173
112
|
const destination = sliceAliasDestination(file, importer, rule.childSlices);
|
|
174
113
|
if (!destination) continue;
|
|
175
114
|
const destFile = `${destination}/${basename(file)}`;
|
|
@@ -177,6 +116,7 @@ export function collectFlatParentPilot(input) {
|
|
|
177
116
|
if (!layer || layer !== layerForRelativePath(destFile, layers)) continue;
|
|
178
117
|
const toPlace = resolveGovernedSlice(destFile, rule);
|
|
179
118
|
if ((toPlace.childId ?? '') !== importer) continue;
|
|
119
|
+
if (moveBreaksWall(entry.importers, destFile, input.rules, layers)) continue;
|
|
180
120
|
const fromPlace = resolveGovernedSlice(file, rule);
|
|
181
121
|
const keepsSlice = destinationKeepsLayerAndSlice(file, destFile, layers, [rule]);
|
|
182
122
|
const wallSame =
|
package/bin/lib/gate-files.mjs
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { codexProjectMcpIsValid } from './codex-home.mjs';
|
|
8
9
|
import { enforcingArkRunText, runsArkCheck } from './github-enforcement.mjs';
|
|
10
|
+
import { parseJsonMergeInputs } from './hook-templates.mjs';
|
|
11
|
+
import { npxArkgatePrefixLength } from './package-manager.mjs';
|
|
9
12
|
|
|
10
13
|
export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
11
|
-
export const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
|
|
12
14
|
|
|
13
15
|
export function readJson(file) {
|
|
14
16
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -41,13 +43,16 @@ export function packageScriptsHaveTypecheck(scripts) {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
+
* Visit the parsed package.json of each shallow nested package — `root/<dir>/`
|
|
47
|
+
* and `root/<dir>/<child>/`, skipping dot directories and `node_modules` — in
|
|
48
|
+
* directory order. Unreadable or invalid files are skipped. Stops at the first
|
|
49
|
+
* visit that returns true.
|
|
50
|
+
*
|
|
46
51
|
* @param {string} root
|
|
52
|
+
* @param {(pkg: any, dir: string) => boolean | void} visit
|
|
53
|
+
* @returns {boolean} true when a visit returned true
|
|
47
54
|
*/
|
|
48
|
-
export function
|
|
49
|
-
const pkg = readPackageJson(root);
|
|
50
|
-
if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
|
|
55
|
+
export function someNestedPackageJson(root, visit) {
|
|
51
56
|
try {
|
|
52
57
|
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
53
58
|
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
@@ -65,8 +70,7 @@ export function treeHasTypecheckScript(root) {
|
|
|
65
70
|
const pj = path.join(dir, 'package.json');
|
|
66
71
|
if (!fs.existsSync(pj)) continue;
|
|
67
72
|
try {
|
|
68
|
-
|
|
69
|
-
if (packageScriptsHaveTypecheck(nested.scripts)) return true;
|
|
73
|
+
if (visit(JSON.parse(fs.readFileSync(pj, 'utf8')), dir) === true) return true;
|
|
70
74
|
} catch {
|
|
71
75
|
/* ignore */
|
|
72
76
|
}
|
|
@@ -78,6 +82,17 @@ export function treeHasTypecheckScript(root) {
|
|
|
78
82
|
return false;
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Root package (and shallow nested packages) already have a typecheck script.
|
|
87
|
+
* Does not scan CI or framework configs — only package.json scripts.
|
|
88
|
+
* @param {string} root
|
|
89
|
+
*/
|
|
90
|
+
export function treeHasTypecheckScript(root) {
|
|
91
|
+
const pkg = readPackageJson(root);
|
|
92
|
+
if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
|
|
93
|
+
return someNestedPackageJson(root, (nested) => packageScriptsHaveTypecheck(nested.scripts));
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
/**
|
|
82
97
|
* Add a conservative `typecheck` script when the host has a TS/JS project config
|
|
83
98
|
* but no typecheck-like script yet. Never overwrites an existing script.
|
|
@@ -86,10 +101,27 @@ export function treeHasTypecheckScript(root) {
|
|
|
86
101
|
* @param {{ write?: boolean }} [opts]
|
|
87
102
|
* @returns {{
|
|
88
103
|
* changed: boolean,
|
|
89
|
-
* reason: 'added' | 'already' | 'no-tsconfig' | 'no-package-json',
|
|
104
|
+
* reason: 'added' | 'already' | 'no-tsconfig' | 'no-package-json' | 'no-typescript',
|
|
90
105
|
* script?: string,
|
|
91
106
|
* }}
|
|
92
107
|
*/
|
|
108
|
+
/**
|
|
109
|
+
* True when `typescript` is declared by the project (deps/devDeps/peerDeps) or resolves
|
|
110
|
+
* from its root (e.g. hoisted in a workspace), so a generated `tsc --noEmit` can run.
|
|
111
|
+
*/
|
|
112
|
+
function projectHasTypeScript(root, pkg = readPackageJson(root) || {}) {
|
|
113
|
+
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
|
114
|
+
const deps = pkg?.[field];
|
|
115
|
+
if (deps && typeof deps === 'object' && Object.hasOwn(deps, 'typescript')) return true;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
createRequire(path.join(root, 'package.json')).resolve('typescript/package.json');
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
93
125
|
export function ensureTypecheckScript(root, opts = {}) {
|
|
94
126
|
const write = opts.write !== false;
|
|
95
127
|
const hasTsconfig =
|
|
@@ -105,6 +137,8 @@ export function ensureTypecheckScript(root, opts = {}) {
|
|
|
105
137
|
}
|
|
106
138
|
|
|
107
139
|
const pkg = readPackageJson(root) || {};
|
|
140
|
+
// `tsc` only exists when typescript resolves — otherwise CI fails with `tsc: not found`.
|
|
141
|
+
if (!projectHasTypeScript(root, pkg)) return { changed: false, reason: 'no-typescript' };
|
|
108
142
|
const scripts =
|
|
109
143
|
pkg.scripts && typeof pkg.scripts === 'object' ? { ...pkg.scripts } : {};
|
|
110
144
|
const script = 'tsc --noEmit';
|
|
@@ -121,7 +155,7 @@ export const REQUIRED_GATE_FILES = [
|
|
|
121
155
|
'.mcp.json',
|
|
122
156
|
];
|
|
123
157
|
export const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
|
|
124
|
-
|
|
158
|
+
const CI_NOT_FAIL_CLOSED_ERROR = 'ci-not-fail-closed';
|
|
125
159
|
const COMPACT_ROUTER = /<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/;
|
|
126
160
|
const FAIL_CLOSED_ARK_FLAG = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;
|
|
127
161
|
|
|
@@ -194,7 +228,10 @@ function arkMcpArgs(server) {
|
|
|
194
228
|
const isArkBin = (value) => /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(executableName(value));
|
|
195
229
|
if ([server.command, ...args].filter(isArkBin).length !== 1) return null;
|
|
196
230
|
if (isArkBin(server.command)) return args;
|
|
197
|
-
|
|
231
|
+
const npxPrefix = command === 'npx' ? npxArkgatePrefixLength(args) : 0;
|
|
232
|
+
if ((command === 'npx' || command === 'yarn') && isArkBin(args[npxPrefix])) {
|
|
233
|
+
return args.slice(npxPrefix + 1);
|
|
234
|
+
}
|
|
198
235
|
if (command === 'pnpm') {
|
|
199
236
|
const binIndex =
|
|
200
237
|
args[0] === 'exec'
|
|
@@ -285,21 +322,17 @@ export function hasArkMcpRegistration(root, relativePath = '.mcp.json') {
|
|
|
285
322
|
*/
|
|
286
323
|
export const MCP_JSON_GATE_FILES = ['.mcp.json', '.cursor/mcp.json', '.agents/mcp_config.json'];
|
|
287
324
|
|
|
325
|
+
/** Claude-style hook files: Ark SessionStart/PreToolUse groups are merged, never whole-file replaced. */
|
|
326
|
+
export const CLAUDE_STYLE_HOOK_FILES = ['.claude/settings.json', '.codex/hooks.json'];
|
|
327
|
+
|
|
288
328
|
/**
|
|
289
329
|
* Upsert mcpServers.ark into an existing MCP JSON document.
|
|
290
330
|
* Preserves sibling servers and unknown top-level keys. Returns null if unreadable.
|
|
291
331
|
*/
|
|
292
332
|
export function mergeArkMcpJson(existingText, generatedText) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
existing = existingText && existingText.trim() ? JSON.parse(existingText) : {};
|
|
297
|
-
generated = JSON.parse(generatedText);
|
|
298
|
-
} catch {
|
|
299
|
-
return null;
|
|
300
|
-
}
|
|
301
|
-
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return null;
|
|
302
|
-
if (!generated || typeof generated !== 'object' || Array.isArray(generated)) return null;
|
|
333
|
+
const parsed = parseJsonMergeInputs(existingText, generatedText);
|
|
334
|
+
if (!parsed) return null;
|
|
335
|
+
const { existing, generated } = parsed;
|
|
303
336
|
const generatedArk = generated.mcpServers?.ark;
|
|
304
337
|
if (!generatedArk || typeof generatedArk !== 'object' || Array.isArray(generatedArk)) {
|
|
305
338
|
return null;
|
|
@@ -577,71 +610,6 @@ function unquoteYamlScalar(value) {
|
|
|
577
610
|
return match ? match[2].trim() : text;
|
|
578
611
|
}
|
|
579
612
|
|
|
580
|
-
function jobCondition(lines, job) {
|
|
581
|
-
const condition = jobProperty(lines, job, 'if');
|
|
582
|
-
if (!condition) return 'default';
|
|
583
|
-
const value = unquoteYamlScalar(condition.value);
|
|
584
|
-
if (/^(?:\$\{\{\s*)?always\(\)(?:\s*\}\})?$/i.test(value)) return 'always';
|
|
585
|
-
if (/^(?:true|\$\{\{\s*true\s*\}\})$/i.test(value)) return 'true';
|
|
586
|
-
return 'conditional';
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
function jobNeeds(lines, job) {
|
|
590
|
-
const property = jobProperty(lines, job, 'needs');
|
|
591
|
-
if (!property) return { ids: [], indexes: [], valid: true };
|
|
592
|
-
const indexes = [property.index];
|
|
593
|
-
if (property.value) {
|
|
594
|
-
const value = unquoteYamlScalar(property.value);
|
|
595
|
-
const raw = value.startsWith('[') && value.endsWith(']')
|
|
596
|
-
? value.slice(1, -1).split(',')
|
|
597
|
-
: [value];
|
|
598
|
-
const ids = raw.map(unquoteYamlScalar).filter((id) => /^[A-Za-z0-9_-]+$/.test(id));
|
|
599
|
-
return { ids, indexes, valid: ids.length === raw.length && ids.length > 0 };
|
|
600
|
-
}
|
|
601
|
-
const ids = [];
|
|
602
|
-
for (let index = property.index + 1; index < job.end; index += 1) {
|
|
603
|
-
if (!lines[index].trim() || /^\s*#/.test(lines[index])) {
|
|
604
|
-
indexes.push(index);
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
|
|
608
|
-
if (indent <= job.propertyIndent) break;
|
|
609
|
-
indexes.push(index);
|
|
610
|
-
const item = lines[index].match(/^\s*-\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/);
|
|
611
|
-
if (!item) return { ids: [], indexes, valid: false };
|
|
612
|
-
ids.push(item[2]);
|
|
613
|
-
}
|
|
614
|
-
return { ids, indexes, valid: ids.length > 0 };
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function withVerifiedDependencyJobs(content) {
|
|
618
|
-
const { lines, jobs } = workflowJobSections(content);
|
|
619
|
-
const byId = new Map(jobs.map((job) => [job.id, job]));
|
|
620
|
-
const guaranteed = (job, seen = new Set()) => {
|
|
621
|
-
if (!job || seen.has(job.id)) return false;
|
|
622
|
-
const condition = jobCondition(lines, job);
|
|
623
|
-
if (condition === 'conditional') return false;
|
|
624
|
-
if (condition === 'always') return true;
|
|
625
|
-
const needs = jobNeeds(lines, job);
|
|
626
|
-
if (!needs.valid) return false;
|
|
627
|
-
const nextSeen = new Set(seen).add(job.id);
|
|
628
|
-
return needs.ids.every((id) => guaranteed(byId.get(id), nextSeen));
|
|
629
|
-
};
|
|
630
|
-
for (const job of jobs) {
|
|
631
|
-
const needs = jobNeeds(lines, job);
|
|
632
|
-
if (
|
|
633
|
-
needs.valid &&
|
|
634
|
-
needs.ids.length > 0 &&
|
|
635
|
-
needs.ids.every((id) => guaranteed(byId.get(id)))
|
|
636
|
-
) {
|
|
637
|
-
// The shared analyzer treats every `needs` as skippable. Hide it only after
|
|
638
|
-
// this dependency chain is proven unconditional; keep uncertain/skipped needs visible.
|
|
639
|
-
for (const index of needs.indexes) lines[index] = '';
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
return lines.join('\n');
|
|
643
|
-
}
|
|
644
|
-
|
|
645
613
|
function isGuaranteedJobCondition(value) {
|
|
646
614
|
const text = unquoteYamlScalar(value);
|
|
647
615
|
return (
|
|
@@ -705,13 +673,13 @@ export function inspectArkCiGate(root) {
|
|
|
705
673
|
} catch {
|
|
706
674
|
continue;
|
|
707
675
|
}
|
|
708
|
-
|
|
676
|
+
// The shared analyzer proves `needs:` chains (github-enforcement.mjs), so this
|
|
677
|
+
// file-level view and doctor merge-gate evidence cannot disagree.
|
|
678
|
+
const prepared = withFailClosedArkActions(content);
|
|
709
679
|
const failClosed = FAIL_CLOSED_ARK_FLAG.test(
|
|
710
680
|
enforcingArkRunText(prepared, failClosedScript)
|
|
711
681
|
);
|
|
712
|
-
const visible =
|
|
713
|
-
neutralizeSkippableJobControls(withFailClosedArkActions(content))
|
|
714
|
-
);
|
|
682
|
+
const visible = neutralizeSkippableJobControls(prepared);
|
|
715
683
|
const present =
|
|
716
684
|
failClosed ||
|
|
717
685
|
runsArkCheck(visible, declaredScript) ||
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git plumbing for diff-scoped checks (`--changed`, `--local`, `--against`, `--persona`).
|
|
3
|
+
*
|
|
4
|
+
* Every path this module returns or reads is relative to the Ark ROOT (the `-C` directory),
|
|
5
|
+
* never to the repository top level, so a package inside a monorepo sees its own files:
|
|
6
|
+
* - `git diff --relative` strips the package prefix and drops paths outside the root;
|
|
7
|
+
* - `git ls-files --others` is already cwd-relative;
|
|
8
|
+
* - `<ref>:./<path>` makes `git show` resolve from the root instead of the top level.
|
|
9
|
+
* Output is NUL-separated (`-z`) so core.quotePath never C-quotes a non-ASCII name.
|
|
10
|
+
* Every listing is checked: a failed or truncated call is an error, never an empty diff.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** Kill hung git instead of stalling CI. */
|
|
16
|
+
export const SPAWN_TIMEOUT_MS = 8000;
|
|
17
|
+
/** Same ceiling as design-delta: a large untracked tree must not overflow the default 1 MiB. */
|
|
18
|
+
export const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/** Refs tried, in order, when no `--base` / `--against` is given. */
|
|
21
|
+
export const TEAM_BASE_CANDIDATES = Object.freeze([
|
|
22
|
+
'origin/dev',
|
|
23
|
+
'origin/main',
|
|
24
|
+
'origin/master',
|
|
25
|
+
'dev',
|
|
26
|
+
'main',
|
|
27
|
+
'master',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function runGit(cwd, args) {
|
|
31
|
+
return spawnSync('git', ['-C', cwd, '-c', 'core.quotePath=false', ...args], {
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
34
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
35
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function safeGitRef(value) {
|
|
40
|
+
return (
|
|
41
|
+
typeof value === 'string' &&
|
|
42
|
+
/^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(value) &&
|
|
43
|
+
!value.includes('..')
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function discoverTeamBaseRef(root, preferred) {
|
|
48
|
+
if (safeGitRef(preferred)) return preferred;
|
|
49
|
+
for (const candidate of TEAM_BASE_CANDIDATES) {
|
|
50
|
+
const exists = runGit(root, ['rev-parse', '--verify', `${candidate}^{commit}`]);
|
|
51
|
+
if (exists.status === 0) return candidate;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function gitFailure(label, result) {
|
|
57
|
+
if (result.error) return `git ${label} failed: ${result.error.code || result.error.message}`;
|
|
58
|
+
if (result.status !== 0) {
|
|
59
|
+
const detail = String(result.stderr || '').trim();
|
|
60
|
+
return `git ${label} failed${detail ? `: ${detail}` : ` (status ${result.status})`}`;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Root-relative paths changed vs `baseRef` (committed since the merge base, staged,
|
|
67
|
+
* unstaged, untracked). Fails closed: any git failure returns `ok: false`.
|
|
68
|
+
*/
|
|
69
|
+
export function listChangedPaths(root, baseRef) {
|
|
70
|
+
if (!safeGitRef(baseRef)) {
|
|
71
|
+
return { ok: false, paths: [], error: 'Invalid or missing git base ref.' };
|
|
72
|
+
}
|
|
73
|
+
const verify = runGit(root, ['rev-parse', '--verify', `${baseRef}^{commit}`]);
|
|
74
|
+
if (verify.status !== 0) {
|
|
75
|
+
return { ok: false, paths: [], error: `Cannot resolve git ref ${baseRef}.` };
|
|
76
|
+
}
|
|
77
|
+
const calls = [
|
|
78
|
+
[`diff ${baseRef}...HEAD`, ['diff', '--relative', '--name-only', '-z', `${baseRef}...HEAD`]],
|
|
79
|
+
['diff (unstaged)', ['diff', '--relative', '--name-only', '-z']],
|
|
80
|
+
['diff --cached', ['diff', '--relative', '--name-only', '-z', '--cached']],
|
|
81
|
+
['ls-files --others', ['ls-files', '-z', '--others', '--exclude-standard']],
|
|
82
|
+
];
|
|
83
|
+
const paths = [];
|
|
84
|
+
for (const [label, gitArgs] of calls) {
|
|
85
|
+
const result = runGit(root, gitArgs);
|
|
86
|
+
const failure = gitFailure(label, result);
|
|
87
|
+
if (failure) return { ok: false, paths: [], error: failure };
|
|
88
|
+
paths.push(...String(result.stdout).split('\0'));
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, paths: [...new Set(paths.filter(Boolean))].sort(), error: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Root-relative form of `relPath` for a `<ref>:./<path>` spec; null when outside the root. */
|
|
94
|
+
export function rootRelativeGitPath(root, relPath) {
|
|
95
|
+
if (typeof relPath !== 'string' || !relPath) return null;
|
|
96
|
+
const rel = path.isAbsolute(relPath) ? path.relative(path.resolve(root), relPath) : relPath;
|
|
97
|
+
const normalized = path.posix.normalize(rel.split(path.sep).join('/'));
|
|
98
|
+
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) return null;
|
|
99
|
+
return normalized.replace(/^\.\//, '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** File text at `baseRef`, resolved relative to the Ark root (not the repo top level). */
|
|
103
|
+
export function gitShowText(root, baseRef, relPath) {
|
|
104
|
+
const rel = rootRelativeGitPath(root, relPath);
|
|
105
|
+
if (!safeGitRef(baseRef) || !rel) return null;
|
|
106
|
+
const shown = runGit(root, ['show', `${baseRef}:./${rel}`]);
|
|
107
|
+
if (shown.error || shown.status !== 0) return null;
|
|
108
|
+
return shown.stdout;
|
|
109
|
+
}
|