@h-rig/standard-plugin 0.0.6-alpha.131 → 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
package/dist/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// packages/standard-plugin/src/index.ts
|
|
3
|
-
import { resolve as
|
|
3
|
+
import { resolve as resolve4 } from "path";
|
|
4
4
|
import { definePlugin } from "@rig/core";
|
|
5
5
|
|
|
6
6
|
// packages/standard-plugin/src/github-issues-source.ts
|
|
@@ -93,17 +93,42 @@ function statusFor(issue) {
|
|
|
93
93
|
return "cancelled";
|
|
94
94
|
return "open";
|
|
95
95
|
}
|
|
96
|
+
function parseIssueRefs(raw) {
|
|
97
|
+
return raw.split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
|
|
98
|
+
}
|
|
99
|
+
function parseMetadataList(body, key) {
|
|
100
|
+
const block = body.match(/<!-- rig:metadata:start -->\s*([\s\S]*?)\s*<!-- rig:metadata:end -->/);
|
|
101
|
+
if (!block)
|
|
102
|
+
return [];
|
|
103
|
+
const lines = block[1].split(/\r?\n/);
|
|
104
|
+
const values = [];
|
|
105
|
+
for (let index = 0;index < lines.length; index += 1) {
|
|
106
|
+
const line = lines[index];
|
|
107
|
+
const sameLine = line.match(new RegExp(`^${key}:\\s*(.+)$`, "i"));
|
|
108
|
+
if (sameLine) {
|
|
109
|
+
values.push(...parseIssueRefs(sameLine[1]));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!new RegExp(`^${key}:\\s*$`, "i").test(line))
|
|
113
|
+
continue;
|
|
114
|
+
for (let cursor = index + 1;cursor < lines.length; cursor += 1) {
|
|
115
|
+
const item = lines[cursor].match(/^\s*-\s*(.+)$/);
|
|
116
|
+
if (!item)
|
|
117
|
+
break;
|
|
118
|
+
values.push(...parseIssueRefs(item[1]));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [...new Set(values)];
|
|
122
|
+
}
|
|
96
123
|
function parseDeps(body) {
|
|
97
124
|
const match = body.match(/^depends-on:\s*([^\n]+)/im);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return match[1].split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
|
|
125
|
+
const bodyRefs = match ? parseIssueRefs(match[1]) : [];
|
|
126
|
+
return [...new Set([...bodyRefs, ...parseMetadataList(body, "depends-on")])];
|
|
101
127
|
}
|
|
102
128
|
function parseParents(body) {
|
|
103
129
|
const match = body.match(/^parents?:\s*([^\n]+)/im);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return match[1].split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
|
|
130
|
+
const bodyRefs = match ? parseIssueRefs(match[1]) : [];
|
|
131
|
+
return [...new Set([...bodyRefs, ...parseMetadataList(body, "parents")])];
|
|
107
132
|
}
|
|
108
133
|
function issueTypeFor(issue) {
|
|
109
134
|
const labels = labelNamesFor(issue);
|
|
@@ -114,7 +139,7 @@ function issueTypeFor(issue) {
|
|
|
114
139
|
return "epic";
|
|
115
140
|
return "task";
|
|
116
141
|
}
|
|
117
|
-
function issueToTask(issue, repo) {
|
|
142
|
+
function issueToTask(issue, repo, nativeDependencies) {
|
|
118
143
|
const labelNames = labelNamesFor(issue);
|
|
119
144
|
const scope = labelNames.filter((l) => l.startsWith("scope:")).map((l) => l.slice("scope:".length));
|
|
120
145
|
const roleLabel = labelNames.find((l) => l.startsWith("role:"));
|
|
@@ -122,10 +147,12 @@ function issueToTask(issue, repo) {
|
|
|
122
147
|
const validators = labelNames.filter((l) => l.startsWith("validator:")).map((l) => l.slice("validator:".length));
|
|
123
148
|
const body = issue.body ?? "";
|
|
124
149
|
const issueNodeId = issue.id ?? issue.nodeId ?? issue.node_id;
|
|
150
|
+
const parsedDeps = parseDeps(body);
|
|
151
|
+
const deps = nativeDependencies?.deps ? [...new Set([...parsedDeps, ...nativeDependencies.deps])] : parsedDeps;
|
|
125
152
|
return {
|
|
126
153
|
id: String(issue.number),
|
|
127
154
|
...typeof issueNodeId === "string" && issueNodeId.trim() ? { issueNodeId: issueNodeId.trim() } : {},
|
|
128
|
-
deps
|
|
155
|
+
deps,
|
|
129
156
|
status: statusFor(issue),
|
|
130
157
|
title: issue.title,
|
|
131
158
|
body,
|
|
@@ -137,6 +164,7 @@ function issueToTask(issue, repo) {
|
|
|
137
164
|
sourceIssueId: `${repo}#${issue.number}`,
|
|
138
165
|
parentChildDeps: parseParents(body),
|
|
139
166
|
labels: labelNames,
|
|
167
|
+
...nativeDependencies?.degraded ? { nativeDependenciesDegraded: true, nativeDependenciesError: nativeDependencies.degraded } : {},
|
|
140
168
|
raw: issue
|
|
141
169
|
};
|
|
142
170
|
}
|
|
@@ -310,6 +338,86 @@ function ghGraphQLFetch(bin, spawnFn, extraEnv, timeoutMs) {
|
|
|
310
338
|
return asProjectRecord(response)?.data ?? response;
|
|
311
339
|
};
|
|
312
340
|
}
|
|
341
|
+
function issueNodeIdFor(issue) {
|
|
342
|
+
const id = issue.id ?? issue.nodeId ?? issue.node_id;
|
|
343
|
+
return typeof id === "string" && id.trim().length > 0 ? id.trim() : null;
|
|
344
|
+
}
|
|
345
|
+
function nativeIssueDependencyRef(value, currentRepo) {
|
|
346
|
+
const record = asProjectRecord(value);
|
|
347
|
+
const number = typeof record?.number === "number" ? String(record.number) : projectString(record?.number);
|
|
348
|
+
if (!number)
|
|
349
|
+
return null;
|
|
350
|
+
const repository = asProjectRecord(record?.repository);
|
|
351
|
+
const owner = projectString(asProjectRecord(repository?.owner)?.login);
|
|
352
|
+
const name = projectString(repository?.name);
|
|
353
|
+
if (!owner || !name || `${owner}/${name}` === currentRepo)
|
|
354
|
+
return number;
|
|
355
|
+
return `${owner}/${name}#${number}`;
|
|
356
|
+
}
|
|
357
|
+
function nativeDependencyRefsFrom(data, currentRepo) {
|
|
358
|
+
const issue = asProjectRecord(asProjectRecord(data)?.node);
|
|
359
|
+
const blockedBy = asProjectRecord(issue?.blockedBy);
|
|
360
|
+
const nodes = Array.isArray(blockedBy?.nodes) ? blockedBy.nodes : [];
|
|
361
|
+
return [...new Set(nodes.flatMap((node) => {
|
|
362
|
+
const ref = nativeIssueDependencyRef(node, currentRepo);
|
|
363
|
+
return ref ? [ref] : [];
|
|
364
|
+
}))];
|
|
365
|
+
}
|
|
366
|
+
async function readNativeDependenciesForIssue(input) {
|
|
367
|
+
const issueId = issueNodeIdFor(input.issue);
|
|
368
|
+
if (!issueId)
|
|
369
|
+
return { deps: [], degraded: "GitHub issue node id is unavailable." };
|
|
370
|
+
const query = `
|
|
371
|
+
query RigIssueNativeDependencies($issueId: ID!) {
|
|
372
|
+
node(id: $issueId) {
|
|
373
|
+
... on Issue {
|
|
374
|
+
blockedBy(first: 100) {
|
|
375
|
+
nodes {
|
|
376
|
+
number
|
|
377
|
+
repository { name owner { login } }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
`;
|
|
384
|
+
try {
|
|
385
|
+
return {
|
|
386
|
+
deps: nativeDependencyRefsFrom(await input.fetchGraphQL(query, { issueId }, "gh-cli"), input.repo)
|
|
387
|
+
};
|
|
388
|
+
} catch (error) {
|
|
389
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
390
|
+
return { deps: [], degraded: detail };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function formatIssueReference(ref) {
|
|
394
|
+
const clean = ref.trim().replace(/^#/, "");
|
|
395
|
+
return /^\d+$/.test(clean) ? `#${clean}` : clean;
|
|
396
|
+
}
|
|
397
|
+
function appendReferenceLines(body, deps, parents) {
|
|
398
|
+
const lines = [];
|
|
399
|
+
const cleanDeps = (deps ?? []).map(formatIssueReference).filter((ref) => ref.length > 0);
|
|
400
|
+
const cleanParents = (parents ?? []).map(formatIssueReference).filter((ref) => ref.length > 0);
|
|
401
|
+
if (cleanDeps.length > 0)
|
|
402
|
+
lines.push(`depends-on: ${cleanDeps.join(", ")}`);
|
|
403
|
+
if (cleanParents.length > 0)
|
|
404
|
+
lines.push(`parents: ${cleanParents.join(", ")}`);
|
|
405
|
+
if (lines.length === 0)
|
|
406
|
+
return body;
|
|
407
|
+
return body.trim().length > 0 ? `${body.trimEnd()}
|
|
408
|
+
|
|
409
|
+
${lines.join(`
|
|
410
|
+
`)}` : lines.join(`
|
|
411
|
+
`);
|
|
412
|
+
}
|
|
413
|
+
function bodyForCreatedTask(input) {
|
|
414
|
+
const metadata = { ...input.metadata ?? {} };
|
|
415
|
+
if (input.deps && input.deps.length > 0)
|
|
416
|
+
metadata["depends-on"] = input.deps.map(formatIssueReference);
|
|
417
|
+
if (input.parents && input.parents.length > 0)
|
|
418
|
+
metadata.parents = input.parents.map(formatIssueReference);
|
|
419
|
+
return updateRigOwnedMetadataBlock(appendReferenceLines(input.body, input.deps, input.parents), metadata);
|
|
420
|
+
}
|
|
313
421
|
function projectStatusFieldFrom(data, projectId) {
|
|
314
422
|
const fields = asProjectRecord(asProjectRecord(asProjectRecord(data)?.node)?.fields)?.nodes;
|
|
315
423
|
for (const node of Array.isArray(fields) ? fields : []) {
|
|
@@ -645,6 +753,16 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
645
753
|
const timeoutMs = Math.max(1000, Math.trunc(opts.timeoutMs ?? DEFAULT_GH_TIMEOUT_MS));
|
|
646
754
|
const listLimit = Math.max(1, Math.trunc(opts.listLimit ?? DEFAULT_GITHUB_ISSUE_LIST_LIMIT));
|
|
647
755
|
const issueUpdates = issueUpdatesMode(opts.issueUpdates);
|
|
756
|
+
async function issueToTaskWithOptionalNativeDependencies(issue, env) {
|
|
757
|
+
if (!opts.useNativeDependencies)
|
|
758
|
+
return issueToTask(issue, repo);
|
|
759
|
+
const nativeDependencies = await readNativeDependenciesForIssue({
|
|
760
|
+
issue,
|
|
761
|
+
repo,
|
|
762
|
+
fetchGraphQL: ghGraphQLFetch(bin, spawnFn, env, timeoutMs)
|
|
763
|
+
});
|
|
764
|
+
return issueToTask(issue, repo, nativeDependencies);
|
|
765
|
+
}
|
|
648
766
|
return {
|
|
649
767
|
id: "std:github-issues",
|
|
650
768
|
kind: "github-issues",
|
|
@@ -671,7 +789,7 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
671
789
|
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.`);
|
|
672
790
|
}
|
|
673
791
|
const issues = rawIssues.filter((issue) => !issue.pull_request);
|
|
674
|
-
return issues.map((
|
|
792
|
+
return Promise.all(issues.map((issue) => issueToTaskWithOptionalNativeDependencies(issue, env)));
|
|
675
793
|
},
|
|
676
794
|
async get(id) {
|
|
677
795
|
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
@@ -688,12 +806,12 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
688
806
|
], spawnFn, env, timeoutMs);
|
|
689
807
|
} catch (error) {
|
|
690
808
|
const detail = error instanceof Error ? error.message : String(error);
|
|
691
|
-
if (/could not resolve to (an? )?(issue|pullrequest)|no issues? (found|matched)|404 not found|gh: not found/i.test(detail)) {
|
|
809
|
+
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)) {
|
|
692
810
|
return;
|
|
693
811
|
}
|
|
694
812
|
throw new Error(`Failed to read task ${id} from GitHub repo ${repo}: ${detail}`);
|
|
695
813
|
}
|
|
696
|
-
return
|
|
814
|
+
return issueToTaskWithOptionalNativeDependencies(issue, env);
|
|
697
815
|
},
|
|
698
816
|
async updateStatus(id, status) {
|
|
699
817
|
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
@@ -717,6 +835,7 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
717
835
|
},
|
|
718
836
|
async createIssue(input) {
|
|
719
837
|
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
838
|
+
const body = input.body ?? "";
|
|
720
839
|
const args = [
|
|
721
840
|
"api",
|
|
722
841
|
"-X",
|
|
@@ -725,12 +844,31 @@ function createGitHubIssuesTaskSource(opts) {
|
|
|
725
844
|
"-f",
|
|
726
845
|
`title=${input.title}`,
|
|
727
846
|
"-f",
|
|
728
|
-
`body=${
|
|
847
|
+
`body=${body}`,
|
|
729
848
|
...(input.labels ?? []).flatMap((label) => ["-f", `labels[]=${label}`])
|
|
730
849
|
];
|
|
731
850
|
const issue = runGh(bin, args, spawnFn, env, timeoutMs);
|
|
732
851
|
notifyTaskChanged(opts.onTaskChanged, repo, String(issue.number));
|
|
733
|
-
return issueToTask(issue, repo);
|
|
852
|
+
return issueToTask({ ...issue, body: issue.body ?? body }, repo);
|
|
853
|
+
},
|
|
854
|
+
async create(input) {
|
|
855
|
+
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
856
|
+
const body = bodyForCreatedTask(input);
|
|
857
|
+
const args = [
|
|
858
|
+
"api",
|
|
859
|
+
"-X",
|
|
860
|
+
"POST",
|
|
861
|
+
`repos/${repo}/issues`,
|
|
862
|
+
"-f",
|
|
863
|
+
`title=${input.title}`,
|
|
864
|
+
"-f",
|
|
865
|
+
`body=${body}`,
|
|
866
|
+
"-f",
|
|
867
|
+
"labels[]=rig:generated"
|
|
868
|
+
];
|
|
869
|
+
const issue = runGh(bin, args, spawnFn, env, timeoutMs);
|
|
870
|
+
notifyTaskChanged(opts.onTaskChanged, repo, String(issue.number));
|
|
871
|
+
return issueToTask({ ...issue, body: issue.body ?? body }, repo);
|
|
734
872
|
},
|
|
735
873
|
async getIssueBody(id) {
|
|
736
874
|
const env = await resolveCredentialEnv(opts, "selected-repo");
|
|
@@ -843,6 +981,425 @@ function createFilesTaskSource(opts) {
|
|
|
843
981
|
};
|
|
844
982
|
}
|
|
845
983
|
|
|
984
|
+
// packages/standard-plugin/src/drift/plugin.ts
|
|
985
|
+
import { Schema } from "effect";
|
|
986
|
+
import { StageMutation as StageMutationSchema } from "@rig/contracts";
|
|
987
|
+
|
|
988
|
+
// packages/standard-plugin/src/drift/detect.ts
|
|
989
|
+
import { existsSync as existsSync3 } from "fs";
|
|
990
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
991
|
+
import { basename as basename2, extname, relative, resolve as resolve3 } from "path";
|
|
992
|
+
|
|
993
|
+
// packages/standard-plugin/src/drift/extract-refs.ts
|
|
994
|
+
var INLINE_CODE = /`([^`\n]+)`/g;
|
|
995
|
+
var MARKDOWN_LINK = /\[[^\]]+\]\(([^)\s]+)\)/g;
|
|
996
|
+
var SYMBOL_REF = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/;
|
|
997
|
+
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)$/;
|
|
998
|
+
function stripFenceLines(markdown) {
|
|
999
|
+
const lines = markdown.split(/\r?\n/);
|
|
1000
|
+
let fenced = false;
|
|
1001
|
+
return lines.map((line) => {
|
|
1002
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
1003
|
+
fenced = !fenced;
|
|
1004
|
+
return "";
|
|
1005
|
+
}
|
|
1006
|
+
return fenced ? "" : line;
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
function normalizeToken(raw) {
|
|
1010
|
+
return raw.trim().replace(/^['"]|['"]$/g, "").replace(/[),.;:]+$/g, "").replace(/#L\d+(?:-L\d+)?$/i, "");
|
|
1011
|
+
}
|
|
1012
|
+
function classifyReference(raw) {
|
|
1013
|
+
if (raw.startsWith("@"))
|
|
1014
|
+
return null;
|
|
1015
|
+
if (PATH_REF.test(raw))
|
|
1016
|
+
return "path";
|
|
1017
|
+
if (SYMBOL_REF.test(raw))
|
|
1018
|
+
return "symbol";
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
function pushReference(refs, seen, raw, line) {
|
|
1022
|
+
const value = normalizeToken(raw);
|
|
1023
|
+
if (!value)
|
|
1024
|
+
return;
|
|
1025
|
+
const kind = classifyReference(value);
|
|
1026
|
+
if (!kind)
|
|
1027
|
+
return;
|
|
1028
|
+
const key = `${kind}:${value}:${line}`;
|
|
1029
|
+
if (seen.has(key))
|
|
1030
|
+
return;
|
|
1031
|
+
seen.add(key);
|
|
1032
|
+
refs.push({ kind, value, line });
|
|
1033
|
+
}
|
|
1034
|
+
function extractDriftReferences(markdown) {
|
|
1035
|
+
const refs = [];
|
|
1036
|
+
const seen = new Set;
|
|
1037
|
+
const lines = stripFenceLines(markdown);
|
|
1038
|
+
for (const [index, line] of lines.entries()) {
|
|
1039
|
+
const lineNumber = index + 1;
|
|
1040
|
+
for (const match of line.matchAll(INLINE_CODE)) {
|
|
1041
|
+
pushReference(refs, seen, match[1] ?? "", lineNumber);
|
|
1042
|
+
}
|
|
1043
|
+
for (const match of line.matchAll(MARKDOWN_LINK)) {
|
|
1044
|
+
pushReference(refs, seen, match[1] ?? "", lineNumber);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return refs;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// packages/standard-plugin/src/drift/git-adapter.ts
|
|
1051
|
+
import { execFile } from "child_process";
|
|
1052
|
+
import { promisify } from "util";
|
|
1053
|
+
var execFileAsync = promisify(execFile);
|
|
1054
|
+
function processError(value) {
|
|
1055
|
+
return value && typeof value === "object" ? value : null;
|
|
1056
|
+
}
|
|
1057
|
+
function lineCount(output) {
|
|
1058
|
+
const trimmed = output.trim();
|
|
1059
|
+
return trimmed ? trimmed.split(/\r?\n/).length : 0;
|
|
1060
|
+
}
|
|
1061
|
+
function makeDriftGit(projectRoot) {
|
|
1062
|
+
async function git(args) {
|
|
1063
|
+
const result = await execFileAsync("git", [...args], {
|
|
1064
|
+
cwd: projectRoot,
|
|
1065
|
+
encoding: "utf8",
|
|
1066
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1067
|
+
});
|
|
1068
|
+
return String(result.stdout);
|
|
1069
|
+
}
|
|
1070
|
+
async function grepCountAt(symbolOrPath, commit) {
|
|
1071
|
+
try {
|
|
1072
|
+
return lineCount(await git(["grep", "-F", "-n", "-e", symbolOrPath, commit, "--"]));
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
const detail = processError(error);
|
|
1075
|
+
if (detail?.code === 1)
|
|
1076
|
+
return 0;
|
|
1077
|
+
throw error;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return {
|
|
1081
|
+
async lastCommitTouching(path) {
|
|
1082
|
+
const commit = (await git(["log", "-n", "1", "--format=%H", "--", path])).trim();
|
|
1083
|
+
return commit || "HEAD";
|
|
1084
|
+
},
|
|
1085
|
+
async grepCount(symbolOrPath) {
|
|
1086
|
+
return grepCountAt(symbolOrPath, "HEAD");
|
|
1087
|
+
},
|
|
1088
|
+
async grepCountAtCommit(symbolOrPath, commit) {
|
|
1089
|
+
return grepCountAt(symbolOrPath, commit);
|
|
1090
|
+
},
|
|
1091
|
+
async wasRenamed(symbolOrPath, sinceCommit) {
|
|
1092
|
+
if (!symbolOrPath.includes("/") && !symbolOrPath.includes("."))
|
|
1093
|
+
return false;
|
|
1094
|
+
try {
|
|
1095
|
+
const output = await git(["log", "--name-status", "--format=", `${sinceCommit}..HEAD`]);
|
|
1096
|
+
return output.split(/\r?\n/).some((line) => {
|
|
1097
|
+
const match = line.match(/^R\d*\s+(.+?)\s+(.+)$/);
|
|
1098
|
+
return Boolean(match && (match[1] === symbolOrPath || match[2] === symbolOrPath));
|
|
1099
|
+
});
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
const detail = processError(error);
|
|
1102
|
+
if (detail?.code === 128)
|
|
1103
|
+
return false;
|
|
1104
|
+
throw error;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// packages/standard-plugin/src/drift/detect.ts
|
|
1111
|
+
var DEFAULT_IGNORED_DIRS = {
|
|
1112
|
+
".git": true,
|
|
1113
|
+
node_modules: true,
|
|
1114
|
+
dist: true,
|
|
1115
|
+
build: true,
|
|
1116
|
+
coverage: true,
|
|
1117
|
+
".next": true,
|
|
1118
|
+
vendor: true
|
|
1119
|
+
};
|
|
1120
|
+
var SOURCE_EXTENSIONS = {
|
|
1121
|
+
".ts": true,
|
|
1122
|
+
".tsx": true,
|
|
1123
|
+
".js": true,
|
|
1124
|
+
".jsx": true,
|
|
1125
|
+
".mjs": true,
|
|
1126
|
+
".cjs": true,
|
|
1127
|
+
".rs": true,
|
|
1128
|
+
".go": true,
|
|
1129
|
+
".py": true,
|
|
1130
|
+
".rb": true,
|
|
1131
|
+
".java": true,
|
|
1132
|
+
".kt": true,
|
|
1133
|
+
".swift": true,
|
|
1134
|
+
".c": true,
|
|
1135
|
+
".cc": true,
|
|
1136
|
+
".cpp": true,
|
|
1137
|
+
".h": true,
|
|
1138
|
+
".hpp": true,
|
|
1139
|
+
".json": true,
|
|
1140
|
+
".toml": true,
|
|
1141
|
+
".yml": true,
|
|
1142
|
+
".yaml": true
|
|
1143
|
+
};
|
|
1144
|
+
function globLikeMatch(path, pattern) {
|
|
1145
|
+
if (pattern === path)
|
|
1146
|
+
return true;
|
|
1147
|
+
if (pattern.startsWith("**/*"))
|
|
1148
|
+
return path.endsWith(pattern.slice(4));
|
|
1149
|
+
if (pattern.endsWith("/**"))
|
|
1150
|
+
return path.startsWith(pattern.slice(0, -3));
|
|
1151
|
+
if (pattern.includes("*")) {
|
|
1152
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
1153
|
+
return new RegExp(`^${escaped}$`).test(path);
|
|
1154
|
+
}
|
|
1155
|
+
return path.startsWith(pattern);
|
|
1156
|
+
}
|
|
1157
|
+
function isDefaultDoc(path) {
|
|
1158
|
+
const lower = basename2(path).toLowerCase();
|
|
1159
|
+
return (path.endsWith(".md") || path.endsWith(".mdx")) && !lower.startsWith("changelog") && !lower.includes("generated");
|
|
1160
|
+
}
|
|
1161
|
+
function isIgnored(path, patterns) {
|
|
1162
|
+
return (patterns ?? []).some((pattern) => globLikeMatch(path, pattern));
|
|
1163
|
+
}
|
|
1164
|
+
async function collectFiles(root, options) {
|
|
1165
|
+
const files = [];
|
|
1166
|
+
async function visit(dir) {
|
|
1167
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
1168
|
+
if (entry.isDirectory() && DEFAULT_IGNORED_DIRS[entry.name])
|
|
1169
|
+
continue;
|
|
1170
|
+
const absolute = resolve3(dir, entry.name);
|
|
1171
|
+
const rel = relative(root, absolute).replace(/\\/g, "/");
|
|
1172
|
+
if (isIgnored(rel, options.ignore))
|
|
1173
|
+
continue;
|
|
1174
|
+
if (entry.isDirectory()) {
|
|
1175
|
+
await visit(absolute);
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
if (!entry.isFile())
|
|
1179
|
+
continue;
|
|
1180
|
+
if (options.docs) {
|
|
1181
|
+
const matchesConfigured = options.patterns && options.patterns.length > 0 ? options.patterns.some((pattern) => globLikeMatch(rel, pattern)) : isDefaultDoc(rel);
|
|
1182
|
+
if (matchesConfigured)
|
|
1183
|
+
files.push(rel);
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
if (SOURCE_EXTENSIONS[extname(entry.name)])
|
|
1187
|
+
files.push(rel);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
await visit(root);
|
|
1191
|
+
return files.sort();
|
|
1192
|
+
}
|
|
1193
|
+
async function sourceReferenceCount(projectRoot, reference, docPath) {
|
|
1194
|
+
if (reference.kind === "path")
|
|
1195
|
+
return existsSync3(resolve3(projectRoot, reference.value)) ? 1 : 0;
|
|
1196
|
+
let count = 0;
|
|
1197
|
+
const sourceFiles = await collectFiles(projectRoot, { docs: false });
|
|
1198
|
+
for (const sourceFile of sourceFiles) {
|
|
1199
|
+
if (sourceFile === docPath)
|
|
1200
|
+
continue;
|
|
1201
|
+
const text = await readFile(resolve3(projectRoot, sourceFile), "utf8").catch(() => "");
|
|
1202
|
+
if (text.includes(reference.value))
|
|
1203
|
+
count += 1;
|
|
1204
|
+
}
|
|
1205
|
+
return count;
|
|
1206
|
+
}
|
|
1207
|
+
function deletedReferenceFinding(docPath, reference) {
|
|
1208
|
+
return {
|
|
1209
|
+
kind: "deleted-reference",
|
|
1210
|
+
docPath,
|
|
1211
|
+
line: reference.line,
|
|
1212
|
+
reference: reference.value,
|
|
1213
|
+
detail: `Documented reference "${reference.value}" no longer exists in the source tree.`,
|
|
1214
|
+
confidence: "high"
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
function staleAnchorFinding(docPath, reference) {
|
|
1218
|
+
return {
|
|
1219
|
+
kind: "stale-anchor",
|
|
1220
|
+
docPath,
|
|
1221
|
+
line: reference.line,
|
|
1222
|
+
reference: reference.value,
|
|
1223
|
+
detail: `Documented path "${reference.value}" changed after this doc was last updated.`,
|
|
1224
|
+
confidence: "medium"
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
async function detectDeletedReferences(projectRoot, docPath, git = makeDriftGit(projectRoot)) {
|
|
1228
|
+
const markdown = await readFile(resolve3(projectRoot, docPath), "utf8");
|
|
1229
|
+
const docCommit = await git.lastCommitTouching(docPath);
|
|
1230
|
+
const findings = [];
|
|
1231
|
+
for (const reference of extractDriftReferences(markdown)) {
|
|
1232
|
+
if (await sourceReferenceCount(projectRoot, reference, docPath) > 0)
|
|
1233
|
+
continue;
|
|
1234
|
+
if (await git.wasRenamed(reference.value, docCommit))
|
|
1235
|
+
continue;
|
|
1236
|
+
findings.push(deletedReferenceFinding(docPath, reference));
|
|
1237
|
+
}
|
|
1238
|
+
return findings;
|
|
1239
|
+
}
|
|
1240
|
+
async function detectStaleAnchors(projectRoot, docPath, git = makeDriftGit(projectRoot)) {
|
|
1241
|
+
const markdown = await readFile(resolve3(projectRoot, docPath), "utf8");
|
|
1242
|
+
const docCommit = await git.lastCommitTouching(docPath);
|
|
1243
|
+
const findings = [];
|
|
1244
|
+
for (const reference of extractDriftReferences(markdown).filter((ref) => ref.kind === "path")) {
|
|
1245
|
+
if (!existsSync3(resolve3(projectRoot, reference.value)))
|
|
1246
|
+
continue;
|
|
1247
|
+
const sourceStat = await stat(resolve3(projectRoot, reference.value)).catch(() => null);
|
|
1248
|
+
if (!sourceStat?.isFile())
|
|
1249
|
+
continue;
|
|
1250
|
+
const sourceCommit = await git.lastCommitTouching(reference.value);
|
|
1251
|
+
if (sourceCommit !== docCommit && !await git.wasRenamed(reference.value, docCommit)) {
|
|
1252
|
+
findings.push(staleAnchorFinding(docPath, reference));
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return findings;
|
|
1256
|
+
}
|
|
1257
|
+
async function detectDrift(options) {
|
|
1258
|
+
const git = options.git ?? makeDriftGit(options.projectRoot);
|
|
1259
|
+
const docs = await collectFiles(options.projectRoot, {
|
|
1260
|
+
docs: true,
|
|
1261
|
+
...options.docsGlobs !== undefined ? { patterns: options.docsGlobs } : {},
|
|
1262
|
+
...options.ignoreGlobs !== undefined ? { ignore: options.ignoreGlobs } : {}
|
|
1263
|
+
});
|
|
1264
|
+
const findings = [];
|
|
1265
|
+
let degraded = false;
|
|
1266
|
+
for (const docPath of docs) {
|
|
1267
|
+
try {
|
|
1268
|
+
findings.push(...await detectDeletedReferences(options.projectRoot, docPath, git));
|
|
1269
|
+
findings.push(...await detectStaleAnchors(options.projectRoot, docPath, git));
|
|
1270
|
+
} catch {
|
|
1271
|
+
degraded = true;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
generatedAt: new Date().toISOString(),
|
|
1276
|
+
scanned: docs.length,
|
|
1277
|
+
degraded,
|
|
1278
|
+
findings
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// packages/standard-plugin/src/drift/plugin.ts
|
|
1283
|
+
var DOCS_DRIFT_VALIDATOR_ID = "std:docs-drift";
|
|
1284
|
+
var DOCS_DRIFT_CLI_ID = "std:drift";
|
|
1285
|
+
var DOCS_DRIFT_STAGE_ID = "docs-drift";
|
|
1286
|
+
var DOCS_DRIFT_VALIDATOR = {
|
|
1287
|
+
id: DOCS_DRIFT_VALIDATOR_ID,
|
|
1288
|
+
category: "regression",
|
|
1289
|
+
description: "Detect documentation references that drifted from the source tree."
|
|
1290
|
+
};
|
|
1291
|
+
var DOCS_DRIFT_STAGE_MUTATION = Schema.decodeUnknownSync(StageMutationSchema)({
|
|
1292
|
+
op: "insert",
|
|
1293
|
+
stage: {
|
|
1294
|
+
id: DOCS_DRIFT_STAGE_ID,
|
|
1295
|
+
kind: "gate",
|
|
1296
|
+
before: ["merge-gate"],
|
|
1297
|
+
after: ["verify"]
|
|
1298
|
+
},
|
|
1299
|
+
contributedBy: DOCS_DRIFT_STAGE_ID
|
|
1300
|
+
});
|
|
1301
|
+
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() });' --`;
|
|
1302
|
+
function highConfidenceDriftFindings(report) {
|
|
1303
|
+
return report.findings.filter((finding) => finding.confidence === "high");
|
|
1304
|
+
}
|
|
1305
|
+
function driftGateResult(report, mode = "enforce") {
|
|
1306
|
+
const high = highConfidenceDriftFindings(report);
|
|
1307
|
+
if (mode === "enforce" && high.length > 0) {
|
|
1308
|
+
return { kind: "block", reason: `${high.length} high-confidence documentation drift finding(s).` };
|
|
1309
|
+
}
|
|
1310
|
+
return { kind: "allow" };
|
|
1311
|
+
}
|
|
1312
|
+
async function runDocsDriftValidation(options) {
|
|
1313
|
+
const report = await detectDrift(options);
|
|
1314
|
+
const high = highConfidenceDriftFindings(report);
|
|
1315
|
+
const passed = options.failOnDrift ? high.length === 0 : true;
|
|
1316
|
+
const findingWord = report.findings.length === 1 ? "finding" : "findings";
|
|
1317
|
+
return {
|
|
1318
|
+
id: DOCS_DRIFT_VALIDATOR_ID,
|
|
1319
|
+
passed,
|
|
1320
|
+
summary: `docs drift scanned ${report.scanned} doc(s), ${report.findings.length} ${findingWord}`,
|
|
1321
|
+
details: JSON.stringify(report)
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
function createDocsDriftValidator(options = {}) {
|
|
1325
|
+
return {
|
|
1326
|
+
...DOCS_DRIFT_VALIDATOR,
|
|
1327
|
+
async run(ctx) {
|
|
1328
|
+
return runDocsDriftValidation({
|
|
1329
|
+
projectRoot: ctx.workspaceRoot,
|
|
1330
|
+
...options.docsGlobs !== undefined ? { docsGlobs: options.docsGlobs } : {},
|
|
1331
|
+
...options.ignoreGlobs !== undefined ? { ignoreGlobs: options.ignoreGlobs } : {},
|
|
1332
|
+
...options.failOnDrift !== undefined ? { failOnDrift: options.failOnDrift } : {}
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
function takeOptionValue(args, index, flag) {
|
|
1338
|
+
const value = args[index + 1];
|
|
1339
|
+
if (!value)
|
|
1340
|
+
throw new Error(`${flag} requires a value`);
|
|
1341
|
+
return value;
|
|
1342
|
+
}
|
|
1343
|
+
async function runDriftCli(args, options = {}) {
|
|
1344
|
+
const docsGlobs = [];
|
|
1345
|
+
const ignoreGlobs = [];
|
|
1346
|
+
let json = false;
|
|
1347
|
+
let failOnDrift = false;
|
|
1348
|
+
for (let index = 0;index < args.length; index += 1) {
|
|
1349
|
+
const arg = args[index];
|
|
1350
|
+
if (arg === "--json") {
|
|
1351
|
+
json = true;
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
if (arg === "--fail-on-drift") {
|
|
1355
|
+
failOnDrift = true;
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
if (arg === "--docs") {
|
|
1359
|
+
docsGlobs.push(takeOptionValue(args, index, arg));
|
|
1360
|
+
index += 1;
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
if (arg === "--ignore") {
|
|
1364
|
+
ignoreGlobs.push(takeOptionValue(args, index, arg));
|
|
1365
|
+
index += 1;
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
throw new Error(`Unknown rig drift argument: ${arg}`);
|
|
1369
|
+
}
|
|
1370
|
+
const report = await detectDrift({
|
|
1371
|
+
projectRoot: options.projectRoot ?? process.cwd(),
|
|
1372
|
+
...docsGlobs.length > 0 ? { docsGlobs } : {},
|
|
1373
|
+
...ignoreGlobs.length > 0 ? { ignoreGlobs } : {}
|
|
1374
|
+
});
|
|
1375
|
+
const write = options.write ?? ((message) => console.log(message));
|
|
1376
|
+
if (json) {
|
|
1377
|
+
write(JSON.stringify(report));
|
|
1378
|
+
} else {
|
|
1379
|
+
write(`Scanned ${report.scanned} doc(s); ${report.findings.length} drift finding(s).`);
|
|
1380
|
+
for (const finding of report.findings) {
|
|
1381
|
+
write(`${finding.confidence.toUpperCase()} ${finding.kind} ${finding.docPath}${finding.line ? `:${finding.line}` : ""} ${finding.reference ?? ""} \u2014 ${finding.detail}`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
const high = highConfidenceDriftFindings(report);
|
|
1385
|
+
if (failOnDrift && high.length > 0) {
|
|
1386
|
+
options.writeError?.(`${high.length} high-confidence drift finding(s).`);
|
|
1387
|
+
return 2;
|
|
1388
|
+
}
|
|
1389
|
+
return 0;
|
|
1390
|
+
}
|
|
1391
|
+
// packages/standard-plugin/src/drift/judge.ts
|
|
1392
|
+
async function judgeDocumentationDrift(provider, input) {
|
|
1393
|
+
const result = await provider.judge(input);
|
|
1394
|
+
return result.mismatches.map((mismatch) => ({
|
|
1395
|
+
kind: "semantic-mismatch",
|
|
1396
|
+
docPath: input.docPath,
|
|
1397
|
+
line: mismatch.line ?? null,
|
|
1398
|
+
reference: mismatch.reference ?? input.reference ?? null,
|
|
1399
|
+
detail: mismatch.detail,
|
|
1400
|
+
confidence: mismatch.confidence ?? "medium"
|
|
1401
|
+
}));
|
|
1402
|
+
}
|
|
846
1403
|
// packages/standard-plugin/src/index.ts
|
|
847
1404
|
function requireStringField(config, field, kind) {
|
|
848
1405
|
const value = config[field];
|
|
@@ -885,11 +1442,15 @@ function githubProjectsOptionsFromConfig(config, context) {
|
|
|
885
1442
|
const github = isRecord(rigConfig?.github) ? rigConfig.github : undefined;
|
|
886
1443
|
return parseGitHubProjectsOptions(config.options?.projects) ?? parseGitHubProjectsOptions(github?.projects);
|
|
887
1444
|
}
|
|
1445
|
+
function booleanOption(value) {
|
|
1446
|
+
return typeof value === "boolean" ? value : undefined;
|
|
1447
|
+
}
|
|
888
1448
|
function standardPlugin(opts = {}) {
|
|
889
1449
|
return definePlugin({
|
|
890
1450
|
name: "rig-standard",
|
|
891
1451
|
version: "0.1.0",
|
|
892
1452
|
contributes: {
|
|
1453
|
+
validators: [DOCS_DRIFT_VALIDATOR],
|
|
893
1454
|
taskSources: [
|
|
894
1455
|
{
|
|
895
1456
|
id: "std:github-issues",
|
|
@@ -901,9 +1462,18 @@ function standardPlugin(opts = {}) {
|
|
|
901
1462
|
kind: "files",
|
|
902
1463
|
description: "JSON files in a local directory"
|
|
903
1464
|
}
|
|
904
|
-
]
|
|
1465
|
+
],
|
|
1466
|
+
cliCommands: [
|
|
1467
|
+
{
|
|
1468
|
+
id: DOCS_DRIFT_CLI_ID,
|
|
1469
|
+
command: DOCS_DRIFT_CLI_COMMAND,
|
|
1470
|
+
description: "Scan documentation for stale code references."
|
|
1471
|
+
}
|
|
1472
|
+
],
|
|
1473
|
+
stageMutations: [DOCS_DRIFT_STAGE_MUTATION]
|
|
905
1474
|
}
|
|
906
1475
|
}, {
|
|
1476
|
+
validators: [createDocsDriftValidator(opts.drift)],
|
|
907
1477
|
taskSources: [
|
|
908
1478
|
{
|
|
909
1479
|
id: "std:github-issues",
|
|
@@ -914,7 +1484,7 @@ function standardPlugin(opts = {}) {
|
|
|
914
1484
|
owner: requireStringField(config, "owner", "github-issues"),
|
|
915
1485
|
repo: requireStringField(config, "repo", "github-issues")
|
|
916
1486
|
};
|
|
917
|
-
const credentialProviderOptions = context?.projectRoot ? { stateDir:
|
|
1487
|
+
const credentialProviderOptions = context?.projectRoot ? { stateDir: resolve4(context.projectRoot, ".rig", "state") } : {};
|
|
918
1488
|
options.credentialProvider = opts.githubCredentialProvider ?? createStateGitHubCredentialProvider(credentialProviderOptions);
|
|
919
1489
|
if (opts.githubWorkspaceId)
|
|
920
1490
|
options.workspaceId = opts.githubWorkspaceId;
|
|
@@ -940,6 +1510,9 @@ function standardPlugin(opts = {}) {
|
|
|
940
1510
|
const projects = githubProjectsOptionsFromConfig(config, context);
|
|
941
1511
|
if (projects)
|
|
942
1512
|
options.projects = projects;
|
|
1513
|
+
const useNativeDependencies = booleanOption(config.options?.useNativeDependencies);
|
|
1514
|
+
if (useNativeDependencies !== undefined)
|
|
1515
|
+
options.useNativeDependencies = useNativeDependencies;
|
|
943
1516
|
return createGitHubIssuesTaskSource(options);
|
|
944
1517
|
}
|
|
945
1518
|
},
|
|
@@ -958,9 +1531,22 @@ function standardPlugin(opts = {}) {
|
|
|
958
1531
|
});
|
|
959
1532
|
}
|
|
960
1533
|
export {
|
|
1534
|
+
runDriftCli,
|
|
1535
|
+
runDocsDriftValidation,
|
|
1536
|
+
makeDriftGit,
|
|
1537
|
+
judgeDocumentationDrift,
|
|
1538
|
+
extractDriftReferences,
|
|
1539
|
+
driftGateResult,
|
|
1540
|
+
detectStaleAnchors,
|
|
1541
|
+
detectDrift,
|
|
1542
|
+
detectDeletedReferences,
|
|
961
1543
|
standardPlugin as default,
|
|
962
1544
|
createStateGitHubCredentialProvider,
|
|
963
1545
|
createGitHubIssuesTaskSource,
|
|
964
1546
|
createFilesTaskSource,
|
|
965
|
-
createEnvGitHubCredentialProvider
|
|
1547
|
+
createEnvGitHubCredentialProvider,
|
|
1548
|
+
createDocsDriftValidator,
|
|
1549
|
+
DOCS_DRIFT_VALIDATOR_ID,
|
|
1550
|
+
DOCS_DRIFT_STAGE_ID,
|
|
1551
|
+
DOCS_DRIFT_CLI_ID
|
|
966
1552
|
};
|