@h-rig/standard-plugin 0.0.6-alpha.132 → 0.0.6-alpha.133
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/dist/src/drift/__fixtures__/temp-repo.d.ts +9 -0
- package/dist/src/drift/__fixtures__/temp-repo.js +41 -0
- package/dist/src/drift/detect.d.ts +11 -0
- package/dist/src/drift/detect.js +299 -0
- package/dist/src/drift/extract-refs.d.ts +7 -0
- package/dist/src/drift/extract-refs.js +60 -0
- package/dist/src/drift/git-adapter.d.ts +7 -0
- package/dist/src/drift/git-adapter.js +63 -0
- package/dist/src/drift/judge.d.ts +19 -0
- package/dist/src/drift/judge.js +16 -0
- package/dist/src/drift/plugin.d.ts +26 -0
- package/dist/src/drift/plugin.js +421 -0
- package/dist/src/github-issues-source.d.ts +2 -0
- package/dist/src/github-issues-source.js +151 -13
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +603 -17
- package/package.json +11 -3
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/standard-plugin/src/drift/plugin.ts
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import { StageMutation as StageMutationSchema } from "@rig/contracts";
|
|
5
|
+
|
|
6
|
+
// packages/standard-plugin/src/drift/detect.ts
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
9
|
+
import { basename, extname, relative, resolve } from "path";
|
|
10
|
+
|
|
11
|
+
// packages/standard-plugin/src/drift/extract-refs.ts
|
|
12
|
+
var INLINE_CODE = /`([^`\n]+)`/g;
|
|
13
|
+
var MARKDOWN_LINK = /\[[^\]]+\]\(([^)\s]+)\)/g;
|
|
14
|
+
var SYMBOL_REF = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/;
|
|
15
|
+
var PATH_REF = /^(?:\.\.?\/)?(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+$|^[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|mdx|css|scss|html|yml|yaml|toml|rs|go|py|rb|java|kt|swift|c|cc|cpp|h|hpp)$/;
|
|
16
|
+
function stripFenceLines(markdown) {
|
|
17
|
+
const lines = markdown.split(/\r?\n/);
|
|
18
|
+
let fenced = false;
|
|
19
|
+
return lines.map((line) => {
|
|
20
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
21
|
+
fenced = !fenced;
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
return fenced ? "" : line;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function normalizeToken(raw) {
|
|
28
|
+
return raw.trim().replace(/^['"]|['"]$/g, "").replace(/[),.;:]+$/g, "").replace(/#L\d+(?:-L\d+)?$/i, "");
|
|
29
|
+
}
|
|
30
|
+
function classifyReference(raw) {
|
|
31
|
+
if (raw.startsWith("@"))
|
|
32
|
+
return null;
|
|
33
|
+
if (PATH_REF.test(raw))
|
|
34
|
+
return "path";
|
|
35
|
+
if (SYMBOL_REF.test(raw))
|
|
36
|
+
return "symbol";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function pushReference(refs, seen, raw, line) {
|
|
40
|
+
const value = normalizeToken(raw);
|
|
41
|
+
if (!value)
|
|
42
|
+
return;
|
|
43
|
+
const kind = classifyReference(value);
|
|
44
|
+
if (!kind)
|
|
45
|
+
return;
|
|
46
|
+
const key = `${kind}:${value}:${line}`;
|
|
47
|
+
if (seen.has(key))
|
|
48
|
+
return;
|
|
49
|
+
seen.add(key);
|
|
50
|
+
refs.push({ kind, value, line });
|
|
51
|
+
}
|
|
52
|
+
function extractDriftReferences(markdown) {
|
|
53
|
+
const refs = [];
|
|
54
|
+
const seen = new Set;
|
|
55
|
+
const lines = stripFenceLines(markdown);
|
|
56
|
+
for (const [index, line] of lines.entries()) {
|
|
57
|
+
const lineNumber = index + 1;
|
|
58
|
+
for (const match of line.matchAll(INLINE_CODE)) {
|
|
59
|
+
pushReference(refs, seen, match[1] ?? "", lineNumber);
|
|
60
|
+
}
|
|
61
|
+
for (const match of line.matchAll(MARKDOWN_LINK)) {
|
|
62
|
+
pushReference(refs, seen, match[1] ?? "", lineNumber);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return refs;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// packages/standard-plugin/src/drift/git-adapter.ts
|
|
69
|
+
import { execFile } from "child_process";
|
|
70
|
+
import { promisify } from "util";
|
|
71
|
+
var execFileAsync = promisify(execFile);
|
|
72
|
+
function processError(value) {
|
|
73
|
+
return value && typeof value === "object" ? value : null;
|
|
74
|
+
}
|
|
75
|
+
function lineCount(output) {
|
|
76
|
+
const trimmed = output.trim();
|
|
77
|
+
return trimmed ? trimmed.split(/\r?\n/).length : 0;
|
|
78
|
+
}
|
|
79
|
+
function makeDriftGit(projectRoot) {
|
|
80
|
+
async function git(args) {
|
|
81
|
+
const result = await execFileAsync("git", [...args], {
|
|
82
|
+
cwd: projectRoot,
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
maxBuffer: 10 * 1024 * 1024
|
|
85
|
+
});
|
|
86
|
+
return String(result.stdout);
|
|
87
|
+
}
|
|
88
|
+
async function grepCountAt(symbolOrPath, commit) {
|
|
89
|
+
try {
|
|
90
|
+
return lineCount(await git(["grep", "-F", "-n", "-e", symbolOrPath, commit, "--"]));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const detail = processError(error);
|
|
93
|
+
if (detail?.code === 1)
|
|
94
|
+
return 0;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
async lastCommitTouching(path) {
|
|
100
|
+
const commit = (await git(["log", "-n", "1", "--format=%H", "--", path])).trim();
|
|
101
|
+
return commit || "HEAD";
|
|
102
|
+
},
|
|
103
|
+
async grepCount(symbolOrPath) {
|
|
104
|
+
return grepCountAt(symbolOrPath, "HEAD");
|
|
105
|
+
},
|
|
106
|
+
async grepCountAtCommit(symbolOrPath, commit) {
|
|
107
|
+
return grepCountAt(symbolOrPath, commit);
|
|
108
|
+
},
|
|
109
|
+
async wasRenamed(symbolOrPath, sinceCommit) {
|
|
110
|
+
if (!symbolOrPath.includes("/") && !symbolOrPath.includes("."))
|
|
111
|
+
return false;
|
|
112
|
+
try {
|
|
113
|
+
const output = await git(["log", "--name-status", "--format=", `${sinceCommit}..HEAD`]);
|
|
114
|
+
return output.split(/\r?\n/).some((line) => {
|
|
115
|
+
const match = line.match(/^R\d*\s+(.+?)\s+(.+)$/);
|
|
116
|
+
return Boolean(match && (match[1] === symbolOrPath || match[2] === symbolOrPath));
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const detail = processError(error);
|
|
120
|
+
if (detail?.code === 128)
|
|
121
|
+
return false;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// packages/standard-plugin/src/drift/detect.ts
|
|
129
|
+
var DEFAULT_IGNORED_DIRS = {
|
|
130
|
+
".git": true,
|
|
131
|
+
node_modules: true,
|
|
132
|
+
dist: true,
|
|
133
|
+
build: true,
|
|
134
|
+
coverage: true,
|
|
135
|
+
".next": true,
|
|
136
|
+
vendor: true
|
|
137
|
+
};
|
|
138
|
+
var SOURCE_EXTENSIONS = {
|
|
139
|
+
".ts": true,
|
|
140
|
+
".tsx": true,
|
|
141
|
+
".js": true,
|
|
142
|
+
".jsx": true,
|
|
143
|
+
".mjs": true,
|
|
144
|
+
".cjs": true,
|
|
145
|
+
".rs": true,
|
|
146
|
+
".go": true,
|
|
147
|
+
".py": true,
|
|
148
|
+
".rb": true,
|
|
149
|
+
".java": true,
|
|
150
|
+
".kt": true,
|
|
151
|
+
".swift": true,
|
|
152
|
+
".c": true,
|
|
153
|
+
".cc": true,
|
|
154
|
+
".cpp": true,
|
|
155
|
+
".h": true,
|
|
156
|
+
".hpp": true,
|
|
157
|
+
".json": true,
|
|
158
|
+
".toml": true,
|
|
159
|
+
".yml": true,
|
|
160
|
+
".yaml": true
|
|
161
|
+
};
|
|
162
|
+
function globLikeMatch(path, pattern) {
|
|
163
|
+
if (pattern === path)
|
|
164
|
+
return true;
|
|
165
|
+
if (pattern.startsWith("**/*"))
|
|
166
|
+
return path.endsWith(pattern.slice(4));
|
|
167
|
+
if (pattern.endsWith("/**"))
|
|
168
|
+
return path.startsWith(pattern.slice(0, -3));
|
|
169
|
+
if (pattern.includes("*")) {
|
|
170
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
171
|
+
return new RegExp(`^${escaped}$`).test(path);
|
|
172
|
+
}
|
|
173
|
+
return path.startsWith(pattern);
|
|
174
|
+
}
|
|
175
|
+
function isDefaultDoc(path) {
|
|
176
|
+
const lower = basename(path).toLowerCase();
|
|
177
|
+
return (path.endsWith(".md") || path.endsWith(".mdx")) && !lower.startsWith("changelog") && !lower.includes("generated");
|
|
178
|
+
}
|
|
179
|
+
function isIgnored(path, patterns) {
|
|
180
|
+
return (patterns ?? []).some((pattern) => globLikeMatch(path, pattern));
|
|
181
|
+
}
|
|
182
|
+
async function collectFiles(root, options) {
|
|
183
|
+
const files = [];
|
|
184
|
+
async function visit(dir) {
|
|
185
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
186
|
+
if (entry.isDirectory() && DEFAULT_IGNORED_DIRS[entry.name])
|
|
187
|
+
continue;
|
|
188
|
+
const absolute = resolve(dir, entry.name);
|
|
189
|
+
const rel = relative(root, absolute).replace(/\\/g, "/");
|
|
190
|
+
if (isIgnored(rel, options.ignore))
|
|
191
|
+
continue;
|
|
192
|
+
if (entry.isDirectory()) {
|
|
193
|
+
await visit(absolute);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!entry.isFile())
|
|
197
|
+
continue;
|
|
198
|
+
if (options.docs) {
|
|
199
|
+
const matchesConfigured = options.patterns && options.patterns.length > 0 ? options.patterns.some((pattern) => globLikeMatch(rel, pattern)) : isDefaultDoc(rel);
|
|
200
|
+
if (matchesConfigured)
|
|
201
|
+
files.push(rel);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (SOURCE_EXTENSIONS[extname(entry.name)])
|
|
205
|
+
files.push(rel);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
await visit(root);
|
|
209
|
+
return files.sort();
|
|
210
|
+
}
|
|
211
|
+
async function sourceReferenceCount(projectRoot, reference, docPath) {
|
|
212
|
+
if (reference.kind === "path")
|
|
213
|
+
return existsSync(resolve(projectRoot, reference.value)) ? 1 : 0;
|
|
214
|
+
let count = 0;
|
|
215
|
+
const sourceFiles = await collectFiles(projectRoot, { docs: false });
|
|
216
|
+
for (const sourceFile of sourceFiles) {
|
|
217
|
+
if (sourceFile === docPath)
|
|
218
|
+
continue;
|
|
219
|
+
const text = await readFile(resolve(projectRoot, sourceFile), "utf8").catch(() => "");
|
|
220
|
+
if (text.includes(reference.value))
|
|
221
|
+
count += 1;
|
|
222
|
+
}
|
|
223
|
+
return count;
|
|
224
|
+
}
|
|
225
|
+
function deletedReferenceFinding(docPath, reference) {
|
|
226
|
+
return {
|
|
227
|
+
kind: "deleted-reference",
|
|
228
|
+
docPath,
|
|
229
|
+
line: reference.line,
|
|
230
|
+
reference: reference.value,
|
|
231
|
+
detail: `Documented reference "${reference.value}" no longer exists in the source tree.`,
|
|
232
|
+
confidence: "high"
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function staleAnchorFinding(docPath, reference) {
|
|
236
|
+
return {
|
|
237
|
+
kind: "stale-anchor",
|
|
238
|
+
docPath,
|
|
239
|
+
line: reference.line,
|
|
240
|
+
reference: reference.value,
|
|
241
|
+
detail: `Documented path "${reference.value}" changed after this doc was last updated.`,
|
|
242
|
+
confidence: "medium"
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async function detectDeletedReferences(projectRoot, docPath, git = makeDriftGit(projectRoot)) {
|
|
246
|
+
const markdown = await readFile(resolve(projectRoot, docPath), "utf8");
|
|
247
|
+
const docCommit = await git.lastCommitTouching(docPath);
|
|
248
|
+
const findings = [];
|
|
249
|
+
for (const reference of extractDriftReferences(markdown)) {
|
|
250
|
+
if (await sourceReferenceCount(projectRoot, reference, docPath) > 0)
|
|
251
|
+
continue;
|
|
252
|
+
if (await git.wasRenamed(reference.value, docCommit))
|
|
253
|
+
continue;
|
|
254
|
+
findings.push(deletedReferenceFinding(docPath, reference));
|
|
255
|
+
}
|
|
256
|
+
return findings;
|
|
257
|
+
}
|
|
258
|
+
async function detectStaleAnchors(projectRoot, docPath, git = makeDriftGit(projectRoot)) {
|
|
259
|
+
const markdown = await readFile(resolve(projectRoot, docPath), "utf8");
|
|
260
|
+
const docCommit = await git.lastCommitTouching(docPath);
|
|
261
|
+
const findings = [];
|
|
262
|
+
for (const reference of extractDriftReferences(markdown).filter((ref) => ref.kind === "path")) {
|
|
263
|
+
if (!existsSync(resolve(projectRoot, reference.value)))
|
|
264
|
+
continue;
|
|
265
|
+
const sourceStat = await stat(resolve(projectRoot, reference.value)).catch(() => null);
|
|
266
|
+
if (!sourceStat?.isFile())
|
|
267
|
+
continue;
|
|
268
|
+
const sourceCommit = await git.lastCommitTouching(reference.value);
|
|
269
|
+
if (sourceCommit !== docCommit && !await git.wasRenamed(reference.value, docCommit)) {
|
|
270
|
+
findings.push(staleAnchorFinding(docPath, reference));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return findings;
|
|
274
|
+
}
|
|
275
|
+
async function detectDrift(options) {
|
|
276
|
+
const git = options.git ?? makeDriftGit(options.projectRoot);
|
|
277
|
+
const docs = await collectFiles(options.projectRoot, {
|
|
278
|
+
docs: true,
|
|
279
|
+
...options.docsGlobs !== undefined ? { patterns: options.docsGlobs } : {},
|
|
280
|
+
...options.ignoreGlobs !== undefined ? { ignore: options.ignoreGlobs } : {}
|
|
281
|
+
});
|
|
282
|
+
const findings = [];
|
|
283
|
+
let degraded = false;
|
|
284
|
+
for (const docPath of docs) {
|
|
285
|
+
try {
|
|
286
|
+
findings.push(...await detectDeletedReferences(options.projectRoot, docPath, git));
|
|
287
|
+
findings.push(...await detectStaleAnchors(options.projectRoot, docPath, git));
|
|
288
|
+
} catch {
|
|
289
|
+
degraded = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
generatedAt: new Date().toISOString(),
|
|
294
|
+
scanned: docs.length,
|
|
295
|
+
degraded,
|
|
296
|
+
findings
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// packages/standard-plugin/src/drift/plugin.ts
|
|
301
|
+
var DOCS_DRIFT_VALIDATOR_ID = "std:docs-drift";
|
|
302
|
+
var DOCS_DRIFT_CLI_ID = "std:drift";
|
|
303
|
+
var DOCS_DRIFT_STAGE_ID = "docs-drift";
|
|
304
|
+
var DOCS_DRIFT_VALIDATOR = {
|
|
305
|
+
id: DOCS_DRIFT_VALIDATOR_ID,
|
|
306
|
+
category: "regression",
|
|
307
|
+
description: "Detect documentation references that drifted from the source tree."
|
|
308
|
+
};
|
|
309
|
+
var DOCS_DRIFT_STAGE_MUTATION = Schema.decodeUnknownSync(StageMutationSchema)({
|
|
310
|
+
op: "insert",
|
|
311
|
+
stage: {
|
|
312
|
+
id: DOCS_DRIFT_STAGE_ID,
|
|
313
|
+
kind: "gate",
|
|
314
|
+
before: ["merge-gate"],
|
|
315
|
+
after: ["verify"]
|
|
316
|
+
},
|
|
317
|
+
contributedBy: DOCS_DRIFT_STAGE_ID
|
|
318
|
+
});
|
|
319
|
+
var DOCS_DRIFT_CLI_COMMAND = `bun -e 'import { runDriftCli } from "@rig/standard-plugin/drift"; process.exitCode = await runDriftCli(process.argv.slice(1), { projectRoot: process.cwd() });' --`;
|
|
320
|
+
function highConfidenceDriftFindings(report) {
|
|
321
|
+
return report.findings.filter((finding) => finding.confidence === "high");
|
|
322
|
+
}
|
|
323
|
+
function driftGateResult(report, mode = "enforce") {
|
|
324
|
+
const high = highConfidenceDriftFindings(report);
|
|
325
|
+
if (mode === "enforce" && high.length > 0) {
|
|
326
|
+
return { kind: "block", reason: `${high.length} high-confidence documentation drift finding(s).` };
|
|
327
|
+
}
|
|
328
|
+
return { kind: "allow" };
|
|
329
|
+
}
|
|
330
|
+
async function runDocsDriftValidation(options) {
|
|
331
|
+
const report = await detectDrift(options);
|
|
332
|
+
const high = highConfidenceDriftFindings(report);
|
|
333
|
+
const passed = options.failOnDrift ? high.length === 0 : true;
|
|
334
|
+
const findingWord = report.findings.length === 1 ? "finding" : "findings";
|
|
335
|
+
return {
|
|
336
|
+
id: DOCS_DRIFT_VALIDATOR_ID,
|
|
337
|
+
passed,
|
|
338
|
+
summary: `docs drift scanned ${report.scanned} doc(s), ${report.findings.length} ${findingWord}`,
|
|
339
|
+
details: JSON.stringify(report)
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function createDocsDriftValidator(options = {}) {
|
|
343
|
+
return {
|
|
344
|
+
...DOCS_DRIFT_VALIDATOR,
|
|
345
|
+
async run(ctx) {
|
|
346
|
+
return runDocsDriftValidation({
|
|
347
|
+
projectRoot: ctx.workspaceRoot,
|
|
348
|
+
...options.docsGlobs !== undefined ? { docsGlobs: options.docsGlobs } : {},
|
|
349
|
+
...options.ignoreGlobs !== undefined ? { ignoreGlobs: options.ignoreGlobs } : {},
|
|
350
|
+
...options.failOnDrift !== undefined ? { failOnDrift: options.failOnDrift } : {}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function takeOptionValue(args, index, flag) {
|
|
356
|
+
const value = args[index + 1];
|
|
357
|
+
if (!value)
|
|
358
|
+
throw new Error(`${flag} requires a value`);
|
|
359
|
+
return value;
|
|
360
|
+
}
|
|
361
|
+
async function runDriftCli(args, options = {}) {
|
|
362
|
+
const docsGlobs = [];
|
|
363
|
+
const ignoreGlobs = [];
|
|
364
|
+
let json = false;
|
|
365
|
+
let failOnDrift = false;
|
|
366
|
+
for (let index = 0;index < args.length; index += 1) {
|
|
367
|
+
const arg = args[index];
|
|
368
|
+
if (arg === "--json") {
|
|
369
|
+
json = true;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (arg === "--fail-on-drift") {
|
|
373
|
+
failOnDrift = true;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (arg === "--docs") {
|
|
377
|
+
docsGlobs.push(takeOptionValue(args, index, arg));
|
|
378
|
+
index += 1;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (arg === "--ignore") {
|
|
382
|
+
ignoreGlobs.push(takeOptionValue(args, index, arg));
|
|
383
|
+
index += 1;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
throw new Error(`Unknown rig drift argument: ${arg}`);
|
|
387
|
+
}
|
|
388
|
+
const report = await detectDrift({
|
|
389
|
+
projectRoot: options.projectRoot ?? process.cwd(),
|
|
390
|
+
...docsGlobs.length > 0 ? { docsGlobs } : {},
|
|
391
|
+
...ignoreGlobs.length > 0 ? { ignoreGlobs } : {}
|
|
392
|
+
});
|
|
393
|
+
const write = options.write ?? ((message) => console.log(message));
|
|
394
|
+
if (json) {
|
|
395
|
+
write(JSON.stringify(report));
|
|
396
|
+
} else {
|
|
397
|
+
write(`Scanned ${report.scanned} doc(s); ${report.findings.length} drift finding(s).`);
|
|
398
|
+
for (const finding of report.findings) {
|
|
399
|
+
write(`${finding.confidence.toUpperCase()} ${finding.kind} ${finding.docPath}${finding.line ? `:${finding.line}` : ""} ${finding.reference ?? ""} \u2014 ${finding.detail}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const high = highConfidenceDriftFindings(report);
|
|
403
|
+
if (failOnDrift && high.length > 0) {
|
|
404
|
+
options.writeError?.(`${high.length} high-confidence drift finding(s).`);
|
|
405
|
+
return 2;
|
|
406
|
+
}
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
export {
|
|
410
|
+
runDriftCli,
|
|
411
|
+
runDocsDriftValidation,
|
|
412
|
+
highConfidenceDriftFindings,
|
|
413
|
+
driftGateResult,
|
|
414
|
+
createDocsDriftValidator,
|
|
415
|
+
DOCS_DRIFT_VALIDATOR_ID,
|
|
416
|
+
DOCS_DRIFT_VALIDATOR,
|
|
417
|
+
DOCS_DRIFT_STAGE_MUTATION,
|
|
418
|
+
DOCS_DRIFT_STAGE_ID,
|
|
419
|
+
DOCS_DRIFT_CLI_ID,
|
|
420
|
+
DOCS_DRIFT_CLI_COMMAND
|
|
421
|
+
};
|
|
@@ -47,6 +47,8 @@ export interface GitHubIssuesOptions {
|
|
|
47
47
|
}) => void;
|
|
48
48
|
/** Optional GitHub Projects (v2) status-field sync mapped from Rig task status. */
|
|
49
49
|
projects?: GitHubProjectsOptions;
|
|
50
|
+
/** Opt into GitHub-native issue dependency reads; body parsing remains the fallback. */
|
|
51
|
+
useNativeDependencies?: boolean;
|
|
50
52
|
}
|
|
51
53
|
export interface GitHubIssueCreateInput {
|
|
52
54
|
title: string;
|
|
@@ -89,17 +89,42 @@ function statusFor(issue) {
|
|
|
89
89
|
return "cancelled";
|
|
90
90
|
return "open";
|
|
91
91
|
}
|
|
92
|
+
function parseIssueRefs(raw) {
|
|
93
|
+
return raw.split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
|
|
94
|
+
}
|
|
95
|
+
function parseMetadataList(body, key) {
|
|
96
|
+
const block = body.match(/<!-- rig:metadata:start -->\s*([\s\S]*?)\s*<!-- rig:metadata:end -->/);
|
|
97
|
+
if (!block)
|
|
98
|
+
return [];
|
|
99
|
+
const lines = block[1].split(/\r?\n/);
|
|
100
|
+
const values = [];
|
|
101
|
+
for (let index = 0;index < lines.length; index += 1) {
|
|
102
|
+
const line = lines[index];
|
|
103
|
+
const sameLine = line.match(new RegExp(`^${key}:\\s*(.+)$`, "i"));
|
|
104
|
+
if (sameLine) {
|
|
105
|
+
values.push(...parseIssueRefs(sameLine[1]));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!new RegExp(`^${key}:\\s*$`, "i").test(line))
|
|
109
|
+
continue;
|
|
110
|
+
for (let cursor = index + 1;cursor < lines.length; cursor += 1) {
|
|
111
|
+
const item = lines[cursor].match(/^\s*-\s*(.+)$/);
|
|
112
|
+
if (!item)
|
|
113
|
+
break;
|
|
114
|
+
values.push(...parseIssueRefs(item[1]));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...new Set(values)];
|
|
118
|
+
}
|
|
92
119
|
function parseDeps(body) {
|
|
93
120
|
const match = body.match(/^depends-on:\s*([^\n]+)/im);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return match[1].split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
|
|
121
|
+
const bodyRefs = match ? parseIssueRefs(match[1]) : [];
|
|
122
|
+
return [...new Set([...bodyRefs, ...parseMetadataList(body, "depends-on")])];
|
|
97
123
|
}
|
|
98
124
|
function parseParents(body) {
|
|
99
125
|
const match = body.match(/^parents?:\s*([^\n]+)/im);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return match[1].split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
|
|
126
|
+
const bodyRefs = match ? parseIssueRefs(match[1]) : [];
|
|
127
|
+
return [...new Set([...bodyRefs, ...parseMetadataList(body, "parents")])];
|
|
103
128
|
}
|
|
104
129
|
function issueTypeFor(issue) {
|
|
105
130
|
const labels = labelNamesFor(issue);
|
|
@@ -110,7 +135,7 @@ function issueTypeFor(issue) {
|
|
|
110
135
|
return "epic";
|
|
111
136
|
return "task";
|
|
112
137
|
}
|
|
113
|
-
function issueToTask(issue, repo) {
|
|
138
|
+
function issueToTask(issue, repo, nativeDependencies) {
|
|
114
139
|
const labelNames = labelNamesFor(issue);
|
|
115
140
|
const scope = labelNames.filter((l) => l.startsWith("scope:")).map((l) => l.slice("scope:".length));
|
|
116
141
|
const roleLabel = labelNames.find((l) => l.startsWith("role:"));
|
|
@@ -118,10 +143,12 @@ function issueToTask(issue, repo) {
|
|
|
118
143
|
const validators = labelNames.filter((l) => l.startsWith("validator:")).map((l) => l.slice("validator:".length));
|
|
119
144
|
const body = issue.body ?? "";
|
|
120
145
|
const issueNodeId = issue.id ?? issue.nodeId ?? issue.node_id;
|
|
146
|
+
const parsedDeps = parseDeps(body);
|
|
147
|
+
const deps = nativeDependencies?.deps ? [...new Set([...parsedDeps, ...nativeDependencies.deps])] : parsedDeps;
|
|
121
148
|
return {
|
|
122
149
|
id: String(issue.number),
|
|
123
150
|
...typeof issueNodeId === "string" && issueNodeId.trim() ? { issueNodeId: issueNodeId.trim() } : {},
|
|
124
|
-
deps
|
|
151
|
+
deps,
|
|
125
152
|
status: statusFor(issue),
|
|
126
153
|
title: issue.title,
|
|
127
154
|
body,
|
|
@@ -133,6 +160,7 @@ function issueToTask(issue, repo) {
|
|
|
133
160
|
sourceIssueId: `${repo}#${issue.number}`,
|
|
134
161
|
parentChildDeps: parseParents(body),
|
|
135
162
|
labels: labelNames,
|
|
163
|
+
...nativeDependencies?.degraded ? { nativeDependenciesDegraded: true, nativeDependenciesError: nativeDependencies.degraded } : {},
|
|
136
164
|
raw: issue
|
|
137
165
|
};
|
|
138
166
|
}
|
|
@@ -306,6 +334,86 @@ function ghGraphQLFetch(bin, spawnFn, extraEnv, timeoutMs) {
|
|
|
306
334
|
return asProjectRecord(response)?.data ?? response;
|
|
307
335
|
};
|
|
308
336
|
}
|
|
337
|
+
function issueNodeIdFor(issue) {
|
|
338
|
+
const id = issue.id ?? issue.nodeId ?? issue.node_id;
|
|
339
|
+
return typeof id === "string" && id.trim().length > 0 ? id.trim() : null;
|
|
340
|
+
}
|
|
341
|
+
function nativeIssueDependencyRef(value, currentRepo) {
|
|
342
|
+
const record = asProjectRecord(value);
|
|
343
|
+
const number = typeof record?.number === "number" ? String(record.number) : projectString(record?.number);
|
|
344
|
+
if (!number)
|
|
345
|
+
return null;
|
|
346
|
+
const repository = asProjectRecord(record?.repository);
|
|
347
|
+
const owner = projectString(asProjectRecord(repository?.owner)?.login);
|
|
348
|
+
const name = projectString(repository?.name);
|
|
349
|
+
if (!owner || !name || `${owner}/${name}` === currentRepo)
|
|
350
|
+
return number;
|
|
351
|
+
return `${owner}/${name}#${number}`;
|
|
352
|
+
}
|
|
353
|
+
function nativeDependencyRefsFrom(data, currentRepo) {
|
|
354
|
+
const issue = asProjectRecord(asProjectRecord(data)?.node);
|
|
355
|
+
const blockedBy = asProjectRecord(issue?.blockedBy);
|
|
356
|
+
const nodes = Array.isArray(blockedBy?.nodes) ? blockedBy.nodes : [];
|
|
357
|
+
return [...new Set(nodes.flatMap((node) => {
|
|
358
|
+
const ref = nativeIssueDependencyRef(node, currentRepo);
|
|
359
|
+
return ref ? [ref] : [];
|
|
360
|
+
}))];
|
|
361
|
+
}
|
|
362
|
+
async function readNativeDependenciesForIssue(input) {
|
|
363
|
+
const issueId = issueNodeIdFor(input.issue);
|
|
364
|
+
if (!issueId)
|
|
365
|
+
return { deps: [], degraded: "GitHub issue node id is unavailable." };
|
|
366
|
+
const query = `
|
|
367
|
+
query RigIssueNativeDependencies($issueId: ID!) {
|
|
368
|
+
node(id: $issueId) {
|
|
369
|
+
... on Issue {
|
|
370
|
+
blockedBy(first: 100) {
|
|
371
|
+
nodes {
|
|
372
|
+
number
|
|
373
|
+
repository { name owner { login } }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
`;
|
|
380
|
+
try {
|
|
381
|
+
return {
|
|
382
|
+
deps: nativeDependencyRefsFrom(await input.fetchGraphQL(query, { issueId }, "gh-cli"), input.repo)
|
|
383
|
+
};
|
|
384
|
+
} catch (error) {
|
|
385
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
386
|
+
return { deps: [], degraded: detail };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function formatIssueReference(ref) {
|
|
390
|
+
const clean = ref.trim().replace(/^#/, "");
|
|
391
|
+
return /^\d+$/.test(clean) ? `#${clean}` : clean;
|
|
392
|
+
}
|
|
393
|
+
function appendReferenceLines(body, deps, parents) {
|
|
394
|
+
const lines = [];
|
|
395
|
+
const cleanDeps = (deps ?? []).map(formatIssueReference).filter((ref) => ref.length > 0);
|
|
396
|
+
const cleanParents = (parents ?? []).map(formatIssueReference).filter((ref) => ref.length > 0);
|
|
397
|
+
if (cleanDeps.length > 0)
|
|
398
|
+
lines.push(`depends-on: ${cleanDeps.join(", ")}`);
|
|
399
|
+
if (cleanParents.length > 0)
|
|
400
|
+
lines.push(`parents: ${cleanParents.join(", ")}`);
|
|
401
|
+
if (lines.length === 0)
|
|
402
|
+
return body;
|
|
403
|
+
return body.trim().length > 0 ? `${body.trimEnd()}
|
|
404
|
+
|
|
405
|
+
${lines.join(`
|
|
406
|
+
`)}` : lines.join(`
|
|
407
|
+
`);
|
|
408
|
+
}
|
|
409
|
+
function bodyForCreatedTask(input) {
|
|
410
|
+
const metadata = { ...input.metadata ?? {} };
|
|
411
|
+
if (input.deps && input.deps.length > 0)
|
|
412
|
+
metadata["depends-on"] = input.deps.map(formatIssueReference);
|
|
413
|
+
if (input.parents && input.parents.length > 0)
|
|
414
|
+
metadata.parents = input.parents.map(formatIssueReference);
|
|
415
|
+
return updateRigOwnedMetadataBlock(appendReferenceLines(input.body, input.deps, input.parents), metadata);
|
|
416
|
+
}
|
|
309
417
|
function projectStatusFieldFrom(data, projectId) {
|
|
310
418
|
const fields = asProjectRecord(asProjectRecord(asProjectRecord(data)?.node)?.fields)?.nodes;
|
|
311
419
|
for (const node of Array.isArray(fields) ? fields : []) {
|
|
@@ -641,6 +749,16 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
641
749
|
const timeoutMs = Math.max(1000, Math.trunc(opts.timeoutMs ?? DEFAULT_GH_TIMEOUT_MS));
|
|
642
750
|
const listLimit = Math.max(1, Math.trunc(opts.listLimit ?? DEFAULT_GITHUB_ISSUE_LIST_LIMIT));
|
|
643
751
|
const issueUpdates = issueUpdatesMode(opts.issueUpdates);
|
|
752
|
+
async function issueToTaskWithOptionalNativeDependencies(issue, env) {
|
|
753
|
+
if (!opts.useNativeDependencies)
|
|
754
|
+
return issueToTask(issue, repo);
|
|
755
|
+
const nativeDependencies = await readNativeDependenciesForIssue({
|
|
756
|
+
issue,
|
|
757
|
+
repo,
|
|
758
|
+
fetchGraphQL: ghGraphQLFetch(bin, spawnFn, env, timeoutMs)
|
|
759
|
+
});
|
|
760
|
+
return issueToTask(issue, repo, nativeDependencies);
|
|
761
|
+
}
|
|
644
762
|
return {
|
|
645
763
|
id: "std:github-issues",
|
|
646
764
|
kind: "github-issues",
|
|
@@ -667,7 +785,7 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
667
785
|
throw new Error(`GitHub issue list for ${repo} reached the configured limit (${listLimit}); refusing to silently truncate matching issues. Increase taskSource.options.listLimit or narrow labels/state/assignee.`);
|
|
668
786
|
}
|
|
669
787
|
const issues = rawIssues.filter((issue) => !issue.pull_request);
|
|
670
|
-
return issues.map((
|
|
788
|
+
return Promise.all(issues.map((issue) => issueToTaskWithOptionalNativeDependencies(issue, env)));
|
|
671
789
|
},
|
|
672
790
|
async get(id) {
|
|
673
791
|
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
@@ -684,12 +802,12 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
684
802
|
], spawnFn, env, timeoutMs);
|
|
685
803
|
} catch (error) {
|
|
686
804
|
const detail = error instanceof Error ? error.message : String(error);
|
|
687
|
-
if (/could not resolve to (an? )?(issue|pullrequest)|no issues? (found|matched)|404 not found|gh: not found/i.test(detail)) {
|
|
805
|
+
if (/could not resolve to (an? )?(issue|pullrequest)|no issues? (found|matched)|404 not found|gh: not found|gh issue view\b[\s\S]*failed \(exit \d+\): not found\b/i.test(detail)) {
|
|
688
806
|
return;
|
|
689
807
|
}
|
|
690
808
|
throw new Error(`Failed to read task ${id} from GitHub repo ${repo}: ${detail}`);
|
|
691
809
|
}
|
|
692
|
-
return
|
|
810
|
+
return issueToTaskWithOptionalNativeDependencies(issue, env);
|
|
693
811
|
},
|
|
694
812
|
async updateStatus(id, status) {
|
|
695
813
|
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
@@ -713,6 +831,7 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
713
831
|
},
|
|
714
832
|
async createIssue(input) {
|
|
715
833
|
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
834
|
+
const body = input.body ?? "";
|
|
716
835
|
const args = [
|
|
717
836
|
"api",
|
|
718
837
|
"-X",
|
|
@@ -721,12 +840,31 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
721
840
|
"-f",
|
|
722
841
|
`title=${input.title}`,
|
|
723
842
|
"-f",
|
|
724
|
-
`body=${
|
|
843
|
+
`body=${body}`,
|
|
725
844
|
...(input.labels ?? []).flatMap((label) => ["-f", `labels[]=${label}`])
|
|
726
845
|
];
|
|
727
846
|
const issue = runGh(bin, args, spawnFn, env, timeoutMs);
|
|
728
847
|
notifyTaskChanged(opts.onTaskChanged, repo, String(issue.number));
|
|
729
|
-
return issueToTask(issue, repo);
|
|
848
|
+
return issueToTask({ ...issue, body: issue.body ?? body }, repo);
|
|
849
|
+
},
|
|
850
|
+
async create(input) {
|
|
851
|
+
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
852
|
+
const body = bodyForCreatedTask(input);
|
|
853
|
+
const args = [
|
|
854
|
+
"api",
|
|
855
|
+
"-X",
|
|
856
|
+
"POST",
|
|
857
|
+
`repos/${repo}/issues`,
|
|
858
|
+
"-f",
|
|
859
|
+
`title=${input.title}`,
|
|
860
|
+
"-f",
|
|
861
|
+
`body=${body}`,
|
|
862
|
+
"-f",
|
|
863
|
+
"labels[]=rig:generated"
|
|
864
|
+
];
|
|
865
|
+
const issue = runGh(bin, args, spawnFn, env, timeoutMs);
|
|
866
|
+
notifyTaskChanged(opts.onTaskChanged, repo, String(issue.number));
|
|
867
|
+
return issueToTask({ ...issue, body: issue.body ?? body }, repo);
|
|
730
868
|
},
|
|
731
869
|
async getIssueBody(id) {
|
|
732
870
|
const env = await resolveCredentialEnv(opts, "selected-repo");
|