arkgate 4.8.9 → 4.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -3
- package/README.md +4 -4
- package/bin/ark-check-runtime.mjs +2 -2
- package/bin/ark.mjs +18 -10
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +91 -4
- package/bin/lib/ark-order-facts.mjs +11 -4
- package/bin/lib/arkrule-file-hints.mjs +255 -20
- package/bin/lib/arkrules-sensors.mjs +364 -68
- package/bin/lib/baseline-key.mjs +45 -1
- package/bin/lib/config-contract.mjs +9 -3
- package/bin/lib/diagnostic-catalog.mjs +1 -0
- package/bin/lib/doctor-human.mjs +3 -3
- package/bin/lib/doctor-next-actions.mjs +3 -1
- package/bin/lib/field-install.mjs +23 -2
- package/bin/lib/first-run-help.mjs +69 -5
- package/bin/lib/resolved-candidate-facts.mjs +82 -1
- package/bin/lib/rules-inventory.mjs +7 -3
- package/bin/lib/upstream-report.mjs +330 -0
- package/bin/lib/violations.mjs +51 -15
- package/dist/{diagnosticCatalog-BrkOiwCk.d.ts → diagnosticCatalog-biferT4R.d.ts} +9 -3
- package/dist/eslint/index.cjs +4 -7
- package/dist/eslint/index.js +4 -7
- package/dist/index.cjs +28 -31
- package/dist/index.d.ts +7 -4
- package/dist/index.js +28 -31
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/runtime/index.cjs +11 -11
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +11 -11
- package/docs/README.md +3 -3
- package/docs/agent-guide.md +25 -1
- package/docs/ai-gates.md +8 -0
- package/docs/brownfield-adoption.md +30 -0
- package/docs/configuration.md +14 -1
- package/docs/diagnostics.md +10 -0
- package/docs/package-surface.md +4 -3
- package/docs/use.md +11 -0
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +3 -2
- package/server.json +2 -2
- package/templates/agent-skills/ark-explore/SKILL.md +22 -1
- package/templates/skills/ark-explore.md +22 -1
|
@@ -116,7 +116,9 @@ export function collectDoctorNextActions(ctx) {
|
|
|
116
116
|
actions.push('review dirty baseline freezes — fix the contract before trusting green-via-freeze');
|
|
117
117
|
}
|
|
118
118
|
if (ctx.analysisComplete && ctx.staleBaseline > 0) {
|
|
119
|
-
actions.push(
|
|
119
|
+
actions.push(
|
|
120
|
+
'tighten the baseline (--update-baseline --force --contract-session --author <steward>)'
|
|
121
|
+
);
|
|
120
122
|
}
|
|
121
123
|
if (ctx.staleRunners.length > 0) {
|
|
122
124
|
actions.push(
|
|
@@ -83,6 +83,27 @@ function addDevDependencyPreservingFormat(source, version) {
|
|
|
83
83
|
return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
const ARK_CHECK_BIN_RE = /\b(?:ark-check|arkgate-check)(?:\.mjs|\.js)?\b/;
|
|
87
|
+
const ARK_CHECK_RUNNER_RE =
|
|
88
|
+
/(?:^|[\s"'`;|&])(?:npx|pnpm|yarn|npm|bunx?|node)(?:\s|$)/;
|
|
89
|
+
const GITHUB_RUN_KEY_RE = /^\s*(?:-\s+)?run:\s+/;
|
|
90
|
+
const YAML_CHECK_JOB_ID_RE =
|
|
91
|
+
/^\s*(?:-\s+)?['"]?(?:ark-check|arkgate-check)['"]?\s*:/;
|
|
92
|
+
const YAML_CONCURRENCY_GROUP_RE = /^\s*group:\s+/;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True when the line invokes ark-check / arkgate-check (npx/pnpm/yarn/npm/node/run).
|
|
96
|
+
* YAML concurrency.group and job-id keys that only contain the name are not invocations.
|
|
97
|
+
*/
|
|
98
|
+
export function isArkCheckInvocationLine(command) {
|
|
99
|
+
if (typeof command !== 'string' || !command.trim()) return false;
|
|
100
|
+
if (/^\s*#/.test(command)) return false;
|
|
101
|
+
if (YAML_CHECK_JOB_ID_RE.test(command)) return false;
|
|
102
|
+
if (YAML_CONCURRENCY_GROUP_RE.test(command)) return false;
|
|
103
|
+
if (!ARK_CHECK_BIN_RE.test(command)) return false;
|
|
104
|
+
return ARK_CHECK_RUNNER_RE.test(command) || GITHUB_RUN_KEY_RE.test(command);
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
/**
|
|
87
108
|
* Ensure a check command string includes `--baseline <file>`.
|
|
88
109
|
* Only touches strings that already invoke ark-check / arkgate-check.
|
|
@@ -97,7 +118,7 @@ export function ensureBaselineFlagInCheckCommand(
|
|
|
97
118
|
if (/^\s*#/.test(command)) {
|
|
98
119
|
return { command, changed: false };
|
|
99
120
|
}
|
|
100
|
-
if (
|
|
121
|
+
if (!isArkCheckInvocationLine(command)) {
|
|
101
122
|
return { command, changed: false };
|
|
102
123
|
}
|
|
103
124
|
if (/(?:^|\s)--baseline(?:\s|=|$)/.test(command)) {
|
|
@@ -180,7 +201,7 @@ export function syncBaselineIntoCheckSurfaces(root, opts = {}) {
|
|
|
180
201
|
let fileChanged = false;
|
|
181
202
|
const nextLines = lines.map((line) => {
|
|
182
203
|
if (/^\s*#/.test(line)) return line;
|
|
183
|
-
if (
|
|
204
|
+
if (!isArkCheckInvocationLine(line)) return line;
|
|
184
205
|
if (/(?:^|\s)--baseline(?:\s|=|$)/.test(line)) return line;
|
|
185
206
|
const { command, changed: c } = ensureBaselineFlagInCheckCommand(line, flagRel);
|
|
186
207
|
if (c) {
|
|
@@ -95,12 +95,74 @@ Non-interactive (no TTY): uses the same defaults as --yes — never calls readli
|
|
|
95
95
|
`;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** `--sensors` is contract + coverage-evidence only — never a full-check pass. */
|
|
99
|
+
export const SENSORS_PARTIAL_MODE_LINE =
|
|
100
|
+
'Contract + coverage-evidence only: no TypeScript, no analysis. Not a validity verdict.';
|
|
101
|
+
|
|
102
|
+
export const SENSORS_DID_NOT_RUN = Object.freeze(['TypeScript', 'analysis']);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stamp a successful `--sensors --json` payload so agents cannot read exit 0 as
|
|
106
|
+
* a full-check pass. Failure payloads (`sensors.ok === false`) stay untouched.
|
|
107
|
+
*/
|
|
108
|
+
export function stampSensorsPartialModePayload(payload) {
|
|
109
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
|
|
110
|
+
const sensors = payload.sensors;
|
|
111
|
+
if (!sensors || typeof sensors !== 'object' || Array.isArray(sensors)) return payload;
|
|
112
|
+
if (sensors.ok === false) return payload;
|
|
113
|
+
return {
|
|
114
|
+
...payload,
|
|
115
|
+
sensors: {
|
|
116
|
+
...sensors,
|
|
117
|
+
notAVerdict: true,
|
|
118
|
+
didNotRun: [...SENSORS_DID_NOT_RUN],
|
|
119
|
+
partialMode: 'contract-only',
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* After `runSensors`, name what this mode skipped. Human success prints the
|
|
126
|
+
* line on stdout; JSON success restamps the captured object. Failures reprint
|
|
127
|
+
* as-is so exit 2 does not look like a map.
|
|
128
|
+
*/
|
|
129
|
+
export async function withSensorsPartialModeHonesty(args, run) {
|
|
130
|
+
if (args?.json) {
|
|
131
|
+
const chunks = [];
|
|
132
|
+
const original = console.log;
|
|
133
|
+
console.log = (...parts) => {
|
|
134
|
+
chunks.push(parts.map(String).join(' '));
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
await run();
|
|
138
|
+
} finally {
|
|
139
|
+
console.log = original;
|
|
140
|
+
}
|
|
141
|
+
const text = chunks.join('\n');
|
|
142
|
+
if ((process.exitCode ?? 0) !== 0) {
|
|
143
|
+
if (text) original(text);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
original(JSON.stringify(stampSensorsPartialModePayload(JSON.parse(text)), null, 2));
|
|
148
|
+
} catch {
|
|
149
|
+
original(text);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await run();
|
|
154
|
+
if ((process.exitCode ?? 0) === 0) {
|
|
155
|
+
console.log(SENSORS_PARTIAL_MODE_LINE);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
98
159
|
export function checkUsage() {
|
|
99
160
|
return [
|
|
100
161
|
'arkgate-check (alias ark-check) — the architecture check.',
|
|
101
162
|
'',
|
|
102
163
|
' arkgate-check --doctor where you are: one status light, one next action',
|
|
103
164
|
' arkgate-check --strict-merge CI / merge gate (required GitHub status)',
|
|
165
|
+
' arkgate-check --sensors which sensors can ever be enforced (does not run analysis)',
|
|
104
166
|
'',
|
|
105
167
|
'Every flag and command: arkgate-check --help --all',
|
|
106
168
|
].join('\n');
|
|
@@ -124,7 +186,7 @@ export function checkUsageAll() {
|
|
|
124
186
|
' Exit 0 ran and clean, 1 drift remains, 2 could not run (no usable base ref).',
|
|
125
187
|
' ark-check --sensors [--json] every sensor with its tier and whether it can EVER be enforced, plus every declared rule',
|
|
126
188
|
' with its local id, the sensor it delegates to, its source file, its mode and why it can or cannot be promoted.',
|
|
127
|
-
|
|
189
|
+
` ${SENSORS_PARTIAL_MODE_LINE} Exit 0 on a report, 2 if the contract will not load.`,
|
|
128
190
|
' ark-check --promote [<ruleId>] [--json] [--apply]',
|
|
129
191
|
' what enforcing would cost: the findings each advisory rule already produces, from ONE run rather than one run per attempt.',
|
|
130
192
|
' Plan by default; --promote <ruleId> --apply (or --promote=<ruleId>) writes mode "enforced" into the ArkRules file that declares it.',
|
|
@@ -143,12 +205,14 @@ export function checkUsageAll() {
|
|
|
143
205
|
' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|vite-vercel-spa|clean-architecture|onion-architecture] [--force] [--follow-config-root]',
|
|
144
206
|
' --follow-config-root On writes (init/install-agent-gates/migrate --write/…), adopt walked-up monorepo config root (default: keep explicit --root)',
|
|
145
207
|
' ark-check --install-agent-gates [--tools claude,cursor,codex,grok,antigravity] [--require-write-hook <host>] [--skills-only] [--codex-home] [--claude-home] [--grok-home] [--antigravity-home] [--agent-homes] [--force]',
|
|
146
|
-
' ark-check --update-baseline [file]
|
|
208
|
+
' ark-check --update-baseline [file] --force --contract-session --author <steward>',
|
|
209
|
+
' freeze current violations (default .ark-baseline.json). --contract-session is required;',
|
|
210
|
+
' --force when freeze-refuse fires; --author when stewards[] is set.',
|
|
147
211
|
' ark-check --print-config eleven-layer',
|
|
148
212
|
'',
|
|
149
|
-
'Adopting Ark in an existing codebase? Run --update-baseline
|
|
150
|
-
'violations, commit the baseline file, and gate CI with --baseline: only NEW
|
|
151
|
-
'fail the check, so the ratchet only moves toward zero.',
|
|
213
|
+
'Adopting Ark in an existing codebase? Run --update-baseline --force --contract-session --author <steward>',
|
|
214
|
+
'once to freeze existing violations, commit the baseline file, and gate CI with --baseline: only NEW',
|
|
215
|
+
'violations fail the check, so the ratchet only moves toward zero.',
|
|
152
216
|
'',
|
|
153
217
|
'Team parliament: law files (ark.config / arkrules / .ark-baseline.json) cannot ship in',
|
|
154
218
|
'the same diff as product source. --changed --base <ref> checks touched files only.',
|
|
@@ -750,6 +750,83 @@ function declaredIntent(value, config) {
|
|
|
750
750
|
);
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
/** Call names whose string arguments are declared intent-reference sites. */
|
|
754
|
+
const INTENT_CALL_NAMES = new Set(['publish', 'subscribe', 'defineIntent', 'registerHandler']);
|
|
755
|
+
|
|
756
|
+
function isSyntaxWrapper(ts, node) {
|
|
757
|
+
return Boolean(
|
|
758
|
+
node &&
|
|
759
|
+
(ts.isParenthesizedExpression(node) ||
|
|
760
|
+
ts.isAsExpression(node) ||
|
|
761
|
+
(typeof ts.isTypeAssertionExpression === 'function' &&
|
|
762
|
+
ts.isTypeAssertionExpression(node)) ||
|
|
763
|
+
(typeof ts.isSatisfiesExpression === 'function' && ts.isSatisfiesExpression(node)))
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function unwrapWrappers(ts, node) {
|
|
768
|
+
let current = node;
|
|
769
|
+
while (current?.parent && isSyntaxWrapper(ts, current.parent)) {
|
|
770
|
+
current = current.parent;
|
|
771
|
+
}
|
|
772
|
+
return current;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function callCalleeName(ts, node) {
|
|
776
|
+
if (!node || !ts.isCallExpression(node)) return undefined;
|
|
777
|
+
const expression = node.expression;
|
|
778
|
+
if (ts.isIdentifier(expression)) return expression.text;
|
|
779
|
+
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function isPublishMetadataSource(ts, sourceProp) {
|
|
784
|
+
const object = sourceProp.parent;
|
|
785
|
+
if (!object || !ts.isObjectLiteralExpression(object)) return false;
|
|
786
|
+
const objectSite = unwrapWrappers(ts, object);
|
|
787
|
+
const objectParent = objectSite.parent;
|
|
788
|
+
if (!objectParent) return false;
|
|
789
|
+
if (ts.isCallExpression(objectParent) && callCalleeName(ts, objectParent) === 'publish') {
|
|
790
|
+
const args = objectParent.arguments;
|
|
791
|
+
return args[1] === objectSite || args[2] === objectSite;
|
|
792
|
+
}
|
|
793
|
+
if (
|
|
794
|
+
ts.isPropertyAssignment(objectParent) &&
|
|
795
|
+
syntaxPropertyName(ts, objectParent.name) === 'metadata'
|
|
796
|
+
) {
|
|
797
|
+
const eventObject = objectParent.parent;
|
|
798
|
+
if (!eventObject) return false;
|
|
799
|
+
const eventSite = unwrapWrappers(ts, eventObject);
|
|
800
|
+
const call = eventSite.parent;
|
|
801
|
+
return Boolean(
|
|
802
|
+
call && ts.isCallExpression(call) && callCalleeName(ts, call) === 'publish'
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Events, sagas, and publish metadata — not every string that matches a prefix. */
|
|
809
|
+
function isDeclaredIntentSite(ts, node) {
|
|
810
|
+
const siteNode = unwrapWrappers(ts, node);
|
|
811
|
+
const parent = siteNode.parent;
|
|
812
|
+
if (!parent) return false;
|
|
813
|
+
if (ts.isCallExpression(parent)) {
|
|
814
|
+
const name = callCalleeName(ts, parent);
|
|
815
|
+
if (INTENT_CALL_NAMES.has(name) && parent.arguments.some((arg) => arg === siteNode)) {
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (ts.isArrayLiteralExpression(parent)) {
|
|
820
|
+
return isDeclaredIntentSite(ts, parent);
|
|
821
|
+
}
|
|
822
|
+
if (ts.isPropertyAssignment(parent)) {
|
|
823
|
+
const name = syntaxPropertyName(ts, parent.name);
|
|
824
|
+
if (name === 'intent' || name === 'onEvent' || name === 'reactsTo') return true;
|
|
825
|
+
if (name === 'source' && isPublishMetadataSource(ts, parent)) return true;
|
|
826
|
+
}
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
|
|
753
830
|
function mayContainForbiddenCapability(ts, sourceFile, forbiddenGlobals) {
|
|
754
831
|
// Every symbol-aware match originates in an identifier or static string path segment.
|
|
755
832
|
// Inspect decoded AST text so escaped identifiers still take the full checker path.
|
|
@@ -788,7 +865,11 @@ function collectPolicyFacts(ts, sourceFile, relativePath, config) {
|
|
|
788
865
|
: {}),
|
|
789
866
|
});
|
|
790
867
|
}
|
|
791
|
-
if (
|
|
868
|
+
if (
|
|
869
|
+
ts.isStringLiteralLike(node) &&
|
|
870
|
+
declaredIntent(node.text, config) &&
|
|
871
|
+
isDeclaredIntentSite(ts, node)
|
|
872
|
+
) {
|
|
792
873
|
intentReferences.push({
|
|
793
874
|
file: relativePath,
|
|
794
875
|
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Pure CLI helper (bin/lib/rules-inventory.mjs). Zero Node I/O.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { DOMAIN_EVENTS_PUSH_RE, DOMAIN_INVARIANT_WORD_RE, expectedDomainInvariantWordsPhrase, isIdiomaticEventsReset, } from './arkrules-sensors.mjs';
|
|
11
12
|
function lineOf(content, index) {
|
|
12
13
|
return content.slice(0, index).split('\n').length;
|
|
13
14
|
}
|
|
@@ -226,7 +227,7 @@ export function buildRulesInventory(input) {
|
|
|
226
227
|
/(?:^|\/)[^/]*(?:-access)?\.error\./i.test(posix) ||
|
|
227
228
|
/(?:^|\/)errors?(?:\/|$)/i.test(posix);
|
|
228
229
|
if (!isErrorBag) {
|
|
229
|
-
const mutRe = /
|
|
230
|
+
const mutRe = /\bthis\.[A-Za-z_][A-Za-z0-9_]*\s*=(?!=)/g;
|
|
230
231
|
let mut;
|
|
231
232
|
while ((mut = mutRe.exec(content)) !== null) {
|
|
232
233
|
const classStart = content.lastIndexOf('class ', mut.index);
|
|
@@ -239,15 +240,18 @@ export function buildRulesInventory(input) {
|
|
|
239
240
|
if (/\bextends\s+(?:Error|[A-Za-z_$][A-Za-z0-9_$]*Error)\b/.test(classHeader)) {
|
|
240
241
|
continue;
|
|
241
242
|
}
|
|
243
|
+
if (isIdiomaticEventsReset(content, mut.index))
|
|
244
|
+
continue;
|
|
242
245
|
const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
|
|
243
|
-
if (
|
|
246
|
+
if (!DOMAIN_INVARIANT_WORD_RE.test(window) &&
|
|
247
|
+
!DOMAIN_EVENTS_PUSH_RE.test(window)) {
|
|
244
248
|
seq += 1;
|
|
245
249
|
candidates.push({
|
|
246
250
|
id: `inv-mut-${seq}`,
|
|
247
251
|
kind: 'mutation-without-guard',
|
|
248
252
|
file,
|
|
249
253
|
line: lineOf(content, mut.index),
|
|
250
|
-
message:
|
|
254
|
+
message: `Domain field mutation without nearby ${expectedDomainInvariantWordsPhrase()}.`,
|
|
251
255
|
confidence: 'heuristic',
|
|
252
256
|
governedLayer,
|
|
253
257
|
suggestedArkRule: {
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream GitHub issue draft for ArkGate itself (`arkgate report` / `ark report`).
|
|
3
|
+
* Target is this package's package.json bugs.url (pedroknigge/arkgate), never the consumer repo.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import readline from 'node:readline/promises';
|
|
9
|
+
|
|
10
|
+
import { __packageRoot } from './gate-files.mjs';
|
|
11
|
+
|
|
12
|
+
export const UPSTREAM_OWNER_REPO = 'pedroknigge/arkgate';
|
|
13
|
+
export const SUBMIT_PROMPT = 'Type submit to send';
|
|
14
|
+
|
|
15
|
+
export function ownerRepoFromGithubUrl(url) {
|
|
16
|
+
if (typeof url !== 'string' || url.trim() === '') return null;
|
|
17
|
+
const match = url.trim().match(/github\.com[:/]+([^/]+)\/([^/#?\s]+)/i);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
const owner = match[1];
|
|
20
|
+
const repo = match[2].replace(/\.git$/i, '');
|
|
21
|
+
if (!owner || !repo) return null;
|
|
22
|
+
return `${owner}/${repo}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveUpstreamRepo(pkg) {
|
|
26
|
+
const fromBugs = ownerRepoFromGithubUrl(pkg?.bugs?.url);
|
|
27
|
+
if (fromBugs) return fromBugs;
|
|
28
|
+
const repository = pkg?.repository;
|
|
29
|
+
const repoUrl = typeof repository === 'string' ? repository : repository?.url;
|
|
30
|
+
const fromRepo = ownerRepoFromGithubUrl(repoUrl);
|
|
31
|
+
if (fromRepo) return fromRepo;
|
|
32
|
+
return UPSTREAM_OWNER_REPO;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function posixSingleQuote(value) {
|
|
36
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatGhIssueCreateCommand({ repo, title, body }) {
|
|
40
|
+
return `gh issue create --repo ${posixSingleQuote(repo)} --title ${posixSingleQuote(title)} --body ${posixSingleQuote(body)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function reportUsage() {
|
|
44
|
+
return `arkgate report (alias ark report) — draft an upstream GitHub issue for ArkGate.
|
|
45
|
+
|
|
46
|
+
Target: pedroknigge/arkgate (this package's package.json bugs.url). Never the consumer repo.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
arkgate report [--root <project>] [--json] [--title <text>] [--finding <ref>]
|
|
50
|
+
arkgate report --submit --i-confirm-submit
|
|
51
|
+
arkgate report --submit # TTY: type submit to send
|
|
52
|
+
|
|
53
|
+
Default prints a draft (arkgate version + last-check snippet). Nothing is created.
|
|
54
|
+
Create only with --submit AND (--i-confirm-submit after the human said yes, or TTY
|
|
55
|
+
"${SUBMIT_PROMPT}"). --yes does not submit.
|
|
56
|
+
|
|
57
|
+
If gh is missing or not logged in: prints the draft and the exact
|
|
58
|
+
gh issue create --repo pedroknigge/arkgate command, then exits 2.
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function lastCheckSnippet(latest) {
|
|
63
|
+
if (!latest || typeof latest !== 'object') return null;
|
|
64
|
+
const at =
|
|
65
|
+
typeof latest.generatedAt === 'string'
|
|
66
|
+
? latest.generatedAt
|
|
67
|
+
: typeof latest.at === 'string'
|
|
68
|
+
? latest.at
|
|
69
|
+
: null;
|
|
70
|
+
const active =
|
|
71
|
+
typeof latest.activeViolations === 'number'
|
|
72
|
+
? latest.activeViolations
|
|
73
|
+
: typeof latest.violations?.active === 'number'
|
|
74
|
+
? latest.violations.active
|
|
75
|
+
: null;
|
|
76
|
+
let verdict = null;
|
|
77
|
+
if (latest.ok === true && (active == null || active === 0)) verdict = 'pass';
|
|
78
|
+
else if (latest.ok === false || (typeof active === 'number' && active > 0)) verdict = 'fail';
|
|
79
|
+
else if (latest.completeness === 'partial' || latest.completeness === 'unavailable') {
|
|
80
|
+
verdict = 'incomplete';
|
|
81
|
+
} else if (latest.ok === true) verdict = 'pass';
|
|
82
|
+
if (at == null && verdict == null && active == null) return null;
|
|
83
|
+
return { at, verdict, activeViolations: active };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readLastCheckSnapshot(root) {
|
|
87
|
+
const latestPath = path.join(root, '.ark', 'reports', 'latest.json');
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(fs.readFileSync(latestPath, 'utf8'));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildIssueDraft({ repo, arkgateVersion, lastCheck, finding, title }) {
|
|
96
|
+
const resolvedTitle =
|
|
97
|
+
typeof title === 'string' && title.trim()
|
|
98
|
+
? title.trim()
|
|
99
|
+
: finding
|
|
100
|
+
? `ArkGate finding ${finding}`
|
|
101
|
+
: 'ArkGate field report';
|
|
102
|
+
const lines = [`## ArkGate version`, String(arkgateVersion ?? 'unknown'), ''];
|
|
103
|
+
if (finding) {
|
|
104
|
+
lines.push('## Finding', String(finding), '');
|
|
105
|
+
}
|
|
106
|
+
if (lastCheck) {
|
|
107
|
+
lines.push('## Last check');
|
|
108
|
+
lines.push(`- at: ${lastCheck.at ?? 'none'}`);
|
|
109
|
+
lines.push(`- verdict: ${lastCheck.verdict ?? 'none'}`);
|
|
110
|
+
lines.push(`- activeViolations: ${lastCheck.activeViolations ?? 'unknown'}`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
} else {
|
|
113
|
+
lines.push('## Last check', 'No last-check snapshot under .ark/reports/latest.json.', '');
|
|
114
|
+
}
|
|
115
|
+
lines.push(
|
|
116
|
+
'## Repro',
|
|
117
|
+
'```bash',
|
|
118
|
+
'npx arkgate-check --doctor',
|
|
119
|
+
'npx arkgate status --json',
|
|
120
|
+
'```',
|
|
121
|
+
'',
|
|
122
|
+
'## What happened',
|
|
123
|
+
'(ArkGate bug, false green, false red, missing doc, or improvable behavior in ArkGate itself — not leftover design in the consumer app.)',
|
|
124
|
+
'',
|
|
125
|
+
`Prepared with \`arkgate report\` against upstream ${repo}. Not the consumer repo.`
|
|
126
|
+
);
|
|
127
|
+
return { repo, title: resolvedTitle, body: lines.join('\n') };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function defaultRunGh(argv, options = {}) {
|
|
131
|
+
const env = { ...(options.env ?? process.env) };
|
|
132
|
+
delete env.GH_REPO;
|
|
133
|
+
const result = spawnSync('gh', argv, {
|
|
134
|
+
encoding: 'utf8',
|
|
135
|
+
env,
|
|
136
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
137
|
+
});
|
|
138
|
+
if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {
|
|
139
|
+
return { missing: true, status: 127, stdout: '', stderr: result.error.message };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
missing: false,
|
|
143
|
+
status: result.status ?? 1,
|
|
144
|
+
stdout: result.stdout ?? '',
|
|
145
|
+
stderr: result.stderr ?? '',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function loadArkgatePackageJson(explicit) {
|
|
150
|
+
if (explicit && typeof explicit === 'object') return explicit;
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(fs.readFileSync(path.join(__packageRoot, 'package.json'), 'utf8'));
|
|
153
|
+
} catch {
|
|
154
|
+
return { bugs: { url: `https://github.com/${UPSTREAM_OWNER_REPO}/issues` } };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function packageVersion(pkg, fallback) {
|
|
159
|
+
if (typeof fallback === 'string' && fallback.trim()) return fallback;
|
|
160
|
+
return typeof pkg?.version === 'string' ? pkg.version : 'unknown';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderHumanDraft(draft, arkgateVersion) {
|
|
164
|
+
return [
|
|
165
|
+
`Upstream: ${draft.repo}`,
|
|
166
|
+
'(never the consumer repo)',
|
|
167
|
+
'',
|
|
168
|
+
`ArkGate version: ${arkgateVersion}`,
|
|
169
|
+
'',
|
|
170
|
+
`Title: ${draft.title}`,
|
|
171
|
+
'',
|
|
172
|
+
draft.body,
|
|
173
|
+
].join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function defaultPromptSubmit(stdin, stdout) {
|
|
177
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
178
|
+
try {
|
|
179
|
+
return await rl.question(`${SUBMIT_PROMPT}\n`);
|
|
180
|
+
} finally {
|
|
181
|
+
rl.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function parseReportArgv(argv = []) {
|
|
186
|
+
const out = {
|
|
187
|
+
submit: false,
|
|
188
|
+
iConfirmSubmit: false,
|
|
189
|
+
json: false,
|
|
190
|
+
yes: false,
|
|
191
|
+
help: false,
|
|
192
|
+
finding: undefined,
|
|
193
|
+
title: undefined,
|
|
194
|
+
root: undefined,
|
|
195
|
+
};
|
|
196
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
197
|
+
const arg = argv[i];
|
|
198
|
+
const next = () => {
|
|
199
|
+
const value = argv[i + 1];
|
|
200
|
+
if (value === undefined || String(value).startsWith('-')) {
|
|
201
|
+
throw new Error(`Missing value for ${arg}. Run arkgate report --help for usage.`);
|
|
202
|
+
}
|
|
203
|
+
i += 1;
|
|
204
|
+
return value;
|
|
205
|
+
};
|
|
206
|
+
if (arg === '--submit') out.submit = true;
|
|
207
|
+
else if (arg === '--i-confirm-submit') out.iConfirmSubmit = true;
|
|
208
|
+
else if (arg === '--json') out.json = true;
|
|
209
|
+
else if (arg === '--yes' || arg === '-y') out.yes = true;
|
|
210
|
+
else if (arg === '--help' || arg === '-h' || arg === 'help') out.help = true;
|
|
211
|
+
else if (arg === '--finding') out.finding = next();
|
|
212
|
+
else if (arg === '--title') out.title = next();
|
|
213
|
+
else if (arg === '--root') out.root = path.resolve(next());
|
|
214
|
+
else throw new Error(`Unknown argument: ${arg}. Run arkgate report --help for usage.`);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {object} options
|
|
221
|
+
* @returns {Promise<number>} process exit code
|
|
222
|
+
*/
|
|
223
|
+
export async function runUpstreamReportCommand(options = {}) {
|
|
224
|
+
const fromArgv = Array.isArray(options.argv) ? parseReportArgv(options.argv) : {};
|
|
225
|
+
if (fromArgv.help) {
|
|
226
|
+
const writeOut = options.writeOut ?? ((text) => console.log(text));
|
|
227
|
+
writeOut(reportUsage());
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
const root = path.resolve(fromArgv.root ?? options.root ?? process.cwd());
|
|
231
|
+
const json = Boolean(fromArgv.json || options.json);
|
|
232
|
+
const submit = Boolean(fromArgv.submit || options.submit);
|
|
233
|
+
const iConfirmSubmit = Boolean(fromArgv.iConfirmSubmit || options.iConfirmSubmit);
|
|
234
|
+
const yes = Boolean(fromArgv.yes || options.yes);
|
|
235
|
+
const finding = fromArgv.finding ?? options.finding;
|
|
236
|
+
const title = fromArgv.title ?? options.title;
|
|
237
|
+
const stdin = options.stdin ?? process.stdin;
|
|
238
|
+
const stdout = options.stdout ?? process.stdout;
|
|
239
|
+
const stdinIsTTY = options.stdinIsTTY ?? Boolean(stdin.isTTY);
|
|
240
|
+
const env = options.env ?? process.env;
|
|
241
|
+
const runGh = options.runGh ?? ((argv) => defaultRunGh(argv, { env }));
|
|
242
|
+
const writeOut = options.writeOut ?? ((text) => console.log(text));
|
|
243
|
+
const writeErr = options.writeErr ?? ((text) => console.error(text));
|
|
244
|
+
|
|
245
|
+
const pkg = loadArkgatePackageJson(options.arkgatePackageJson);
|
|
246
|
+
const repo = resolveUpstreamRepo(pkg);
|
|
247
|
+
const arkgateVersion = packageVersion(pkg, options.arkgateVersion);
|
|
248
|
+
const lastCheck = lastCheckSnippet(options.latestSnapshot ?? readLastCheckSnapshot(root));
|
|
249
|
+
const draft = buildIssueDraft({
|
|
250
|
+
repo,
|
|
251
|
+
arkgateVersion,
|
|
252
|
+
lastCheck,
|
|
253
|
+
finding,
|
|
254
|
+
title,
|
|
255
|
+
});
|
|
256
|
+
const ghCommand = formatGhIssueCreateCommand(draft);
|
|
257
|
+
const human = renderHumanDraft(draft, arkgateVersion);
|
|
258
|
+
|
|
259
|
+
const payload = {
|
|
260
|
+
schemaVersion: '1.0',
|
|
261
|
+
command: 'report',
|
|
262
|
+
created: false,
|
|
263
|
+
submitted: false,
|
|
264
|
+
repo: draft.repo,
|
|
265
|
+
title: draft.title,
|
|
266
|
+
body: draft.body,
|
|
267
|
+
arkgateVersion,
|
|
268
|
+
lastCheck,
|
|
269
|
+
ghCommand,
|
|
270
|
+
yesDoesNotSubmit: true,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const emit = (extraHuman, extraPayload) => {
|
|
274
|
+
if (json) writeOut(JSON.stringify({ ...payload, ...extraPayload }, null, 2));
|
|
275
|
+
else writeOut(extraHuman ? `${human}\n\n${extraHuman}` : human);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (!submit) {
|
|
279
|
+
emit('Draft only. Nothing was created.\nAfter a human confirms: arkgate report --submit --i-confirm-submit\n--yes does not submit.');
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --yes never confirms. Confirm is --i-confirm-submit or TTY "submit".
|
|
284
|
+
let confirmed = iConfirmSubmit;
|
|
285
|
+
if (!confirmed && stdinIsTTY) {
|
|
286
|
+
const typed = options.promptSubmit
|
|
287
|
+
? await options.promptSubmit()
|
|
288
|
+
: await defaultPromptSubmit(stdin, stdout);
|
|
289
|
+
confirmed = /^\s*submit\s*$/i.test(String(typed ?? ''));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!confirmed) {
|
|
293
|
+
const refused =
|
|
294
|
+
'Refused: --submit requires --i-confirm-submit or typing submit on a TTY. --yes does not submit.';
|
|
295
|
+
if (json) {
|
|
296
|
+
writeOut(JSON.stringify({ ...payload, error: 'submit-confirm-required', yesDoesNotSubmit: true }, null, 2));
|
|
297
|
+
} else {
|
|
298
|
+
writeOut(`${human}\n\nDraft only. ${refused}`);
|
|
299
|
+
}
|
|
300
|
+
writeErr(refused);
|
|
301
|
+
return 2;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const auth = runGh(['auth', 'status']);
|
|
305
|
+
const ghUnavailable = Boolean(auth?.missing) || auth?.status !== 0;
|
|
306
|
+
if (ghUnavailable) {
|
|
307
|
+
const missing =
|
|
308
|
+
'Not filed: gh is missing or not logged in.\nExact command:\n' + ghCommand;
|
|
309
|
+
emit(missing, { created: false, submitted: false, error: 'gh-unavailable', ghCommand });
|
|
310
|
+
if (!json) writeErr('Not filed: gh is missing or not logged in.');
|
|
311
|
+
return 2;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const created = runGh(['issue', 'create', '--repo', repo, '--title', draft.title, '--body', draft.body]);
|
|
315
|
+
if (created?.missing || created?.status !== 0) {
|
|
316
|
+
const missing =
|
|
317
|
+
'Not filed: gh is missing or not logged in.\nExact command:\n' + ghCommand;
|
|
318
|
+
emit(missing, { created: false, submitted: false, error: 'gh-unavailable', ghCommand });
|
|
319
|
+
if (!json) writeErr('Not filed: gh is missing or not logged in.');
|
|
320
|
+
return 2;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const url = String(created.stdout ?? '').trim();
|
|
324
|
+
emit(url ? `Created: ${url}` : 'Created.', {
|
|
325
|
+
created: true,
|
|
326
|
+
submitted: true,
|
|
327
|
+
url: url || null,
|
|
328
|
+
});
|
|
329
|
+
return 0;
|
|
330
|
+
}
|