@clearlydev/nx-archweave 0.2.1 → 0.3.0

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/README.md CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  Nx generators that get a workspace ready to work on with one preset:
4
4
 
5
- - FSD Lite structure with enforced module boundaries
5
+ - an FSD Lite lint rule for the frontend app's layers (`warn` or `error` by preset), plus slice generators
6
+ - frontend/backend boundaries as lint errors between the projects it tags, when there is a backend
6
7
  - AI agent rules
7
8
  - GitHub templates
8
- - optional AI PR review (Claude, Gemini, GPT, Cursor)
9
+ - optional AI PR review with the provider you choose (Claude supported; Gemini, GPT and Cursor in beta)
9
10
 
10
11
  Starting a brand-new workspace instead? Use [`create-nx-archweave`](https://www.npmjs.com/package/create-nx-archweave). It creates the workspace, installs this plugin, and runs `init`, all in one command.
11
12
 
@@ -32,7 +33,7 @@ bun nx g @clearlydev/nx-archweave:doctor # bun
32
33
  - **Requirements:** Node >= 22.18, plus one of npm, pnpm >= 10, yarn (classic or berry) or bun. All four package managers are first-class.
33
34
  - Generators detect the workspace's real package manager from its lockfiles.
34
35
  - The examples below use `pnpm nx g`. With another package manager, run the same generator the way the `doctor` commands above show.
35
- - The package ships as a self-contained bundle. Its only runtime dependency is `@nx/devkit`.
36
+ - The package ships its own code as a bundle. Its runtime dependencies are `@nx/devkit` and `jsonc-parser`, which nx already depends on at the same version.
36
37
 
37
38
  ## Quick start
38
39
 
@@ -213,9 +214,11 @@ Review is fully optional and isn't tied to one provider.
213
214
  | `--provider` | Workflow file | Required secret |
214
215
  | --- | --- | --- |
215
216
  | `claude` (default) | `.github/workflows/claude-code-review.yml` | `CLAUDE_CODE_OAUTH_TOKEN` |
216
- | `gemini` | `.github/workflows/gemini-code-review.yml` | `GEMINI_API_KEY` |
217
- | `gpt` | `.github/workflows/gpt-code-review.yml` | `OPENAI_API_KEY` |
218
- | `cursor` | `.github/workflows/cursor-code-review.yml` | `CURSOR_API_KEY` |
217
+ | `gemini` (beta) | `.github/workflows/gemini-code-review.yml` | `GEMINI_API_KEY` |
218
+ | `gpt` (beta) | `.github/workflows/gpt-code-review.yml` | `OPENAI_API_KEY` |
219
+ | `cursor` (beta) | `.github/workflows/cursor-code-review.yml` | `CURSOR_API_KEY` |
220
+
221
+ What each provider has been checked on, where to get its secret, and known issues: [Provider status](https://github.com/clearlydev/nx-archweave/blob/main/docs/pr-review.md#provider-status).
219
222
 
220
223
  Modes:
221
224
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  // src/bin/review-verdict.ts
4
4
  import { spawnSync } from "node:child_process";
5
- import { realpathSync } from "node:fs";
5
+ import { randomBytes } from "node:crypto";
6
+ import { appendFileSync, realpathSync } from "node:fs";
6
7
 
7
8
  // ../review-policy/dist/schema/review-finding.js
8
9
  function computeVerdict(findings) {
@@ -149,15 +150,29 @@ var NO_ROUND_NOTICE = {
149
150
  en: "\u26A0\uFE0F This run of the review posted no summary comment for the current commit, so there is no verdict for it (an earlier round is never reused). The verdict gate stays green; please review this PR manually.",
150
151
  ko: "\u26A0\uFE0F \uC774\uBC88 \uB9AC\uBDF0 \uC2E4\uD589\uC774 \uD604\uC7AC \uCEE4\uBC0B\uC5D0 \uB300\uD55C \uC694\uC57D \uCF54\uBA58\uD2B8\uB97C \uAC8C\uC2DC\uD558\uC9C0 \uC54A\uC544 verdict\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4(\uC774\uC804 \uB77C\uC6B4\uB4DC\uB294 \uC7AC\uC0AC\uC6A9\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4). verdict \uAC8C\uC774\uD2B8\uB294 \uB179\uC0C9\uC73C\uB85C \uB450\uB2C8 \uC774 PR\uC740 \uC0AC\uB78C\uC774 \uC9C1\uC811 \uB9AC\uBDF0\uD574 \uC8FC\uC138\uC694."
151
152
  };
153
+ var NO_SUMMARY_NOTICE = {
154
+ en: "\u26D4 AI review posted no summary for this commit, so there is no verdict for it and the review check fails. Review this PR manually, or re-run all jobs to review it again.",
155
+ ko: "\u26D4 AI \uB9AC\uBDF0\uAC00 \uC774 \uCEE4\uBC0B\uC5D0 \uB300\uD55C \uC694\uC57D\uC744 \uAC8C\uC2DC\uD558\uC9C0 \uC54A\uC544 verdict\uAC00 \uC5C6\uACE0, review \uCCB4\uD06C\uB294 \uC2E4\uD328\uD569\uB2C8\uB2E4. \uC774 PR\uC744 \uC9C1\uC811 \uB9AC\uBDF0\uD558\uAC70\uB098, \uBAA8\uB4E0 \uC7A1\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD574 \uB2E4\uC2DC \uB9AC\uBDF0\uBC1B\uC73C\uC138\uC694."
156
+ };
157
+ var UNREADABLE_SUMMARY_NOTICE = {
158
+ en: "\u26D4 AI review posted a summary whose findings block could not be machine-read (missing or invalid), so there is no verdict for this commit and the review check fails. Review this PR manually, or re-run all jobs to review it again.",
159
+ ko: "\u26D4 AI \uB9AC\uBDF0 \uC694\uC57D\uC758 findings \uBE14\uB85D\uC744 \uAE30\uACC4 \uD310\uB3C5\uD558\uC9C0 \uBABB\uD574(\uB204\uB77D \uB610\uB294 \uC190\uC0C1) \uC774 \uCEE4\uBC0B\uC758 verdict\uAC00 \uC5C6\uACE0, review \uCCB4\uD06C\uB294 \uC2E4\uD328\uD569\uB2C8\uB2E4. \uC774 PR\uC744 \uC9C1\uC811 \uB9AC\uBDF0\uD558\uAC70\uB098, \uBAA8\uB4E0 \uC7A1\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD574 \uB2E4\uC2DC \uB9AC\uBDF0\uBC1B\uC73C\uC138\uC694."
160
+ };
161
+ function renderCommitLine(locale, shortSha) {
162
+ return locale === "en" ? `<sub>Commit \`${shortSha}\` \xB7 this comment is updated on every review round.</sub>` : `<sub>\uCEE4\uBC0B \`${shortSha}\` \xB7 \uC774 \uCF54\uBA58\uD2B8\uB294 \uB9AC\uBDF0 \uB77C\uC6B4\uB4DC\uB9C8\uB2E4 \uAC31\uC2E0\uB429\uB2C8\uB2E4.</sub>`;
163
+ }
152
164
  var HEAD_LINE = {
153
- en: (sha) => `<sub>Commit \`${sha.slice(0, 7)}\` \xB7 this comment is updated on every review round.</sub>`,
154
- ko: (sha) => `<sub>\uCEE4\uBC0B \`${sha.slice(0, 7)}\` \xB7 \uC774 \uCF54\uBA58\uD2B8\uB294 \uB9AC\uBDF0 \uB77C\uC6B4\uB4DC\uB9C8\uB2E4 \uAC31\uC2E0\uB429\uB2C8\uB2E4.</sub>`
165
+ en: (sha) => renderCommitLine("en", sha.slice(0, 7)),
166
+ ko: (sha) => renderCommitLine("ko", sha.slice(0, 7))
155
167
  };
156
168
  function withHead(lines, locale, headSha) {
157
169
  return (headSha === void 0 ? lines : [...lines, "", HEAD_LINE[locale](headSha)]).join("\n");
158
170
  }
159
- function renderUnparseableNotice(locale, headSha) {
160
- return withHead([VERDICT_MARKER, UNPARSEABLE_NOTICE[locale]], locale, headSha);
171
+ function renderUnparseableNotice(locale, headSha, failClosed = false) {
172
+ return withHead([VERDICT_MARKER, (failClosed ? UNREADABLE_SUMMARY_NOTICE : UNPARSEABLE_NOTICE)[locale]], locale, headSha);
173
+ }
174
+ function renderNoSummaryNotice(locale, headSha) {
175
+ return withHead([VERDICT_MARKER, NO_SUMMARY_NOTICE[locale]], locale, headSha);
161
176
  }
162
177
  function renderNoRoundNotice(locale, headSha) {
163
178
  return withHead([VERDICT_MARKER, NO_ROUND_NOTICE[locale]], locale, headSha);
@@ -205,14 +220,16 @@ function computeRoundHistory(rounds) {
205
220
  }
206
221
  function runVerdictGate(input) {
207
222
  const rounds = collectRounds(input.comments);
208
- if (rounds.length === 0)
209
- return { kind: "no-review" };
223
+ const reviewRan = input.reviewRan === true;
224
+ if (rounds.length === 0) {
225
+ return reviewRan ? { kind: "no-summary", commentBody: renderNoSummaryNotice(input.locale, input.headSha) } : { kind: "no-review" };
226
+ }
210
227
  const latest = rounds[rounds.length - 1];
211
228
  if (input.roundAfterId !== void 0 && (latest.id === void 0 || latest.id <= input.roundAfterId)) {
212
- return { kind: "no-round", commentBody: renderNoRoundNotice(input.locale, input.headSha) };
229
+ return reviewRan ? { kind: "no-summary", commentBody: renderNoSummaryNotice(input.locale, input.headSha) } : { kind: "no-round", commentBody: renderNoRoundNotice(input.locale, input.headSha) };
213
230
  }
214
231
  if (latest.extraction.status !== "ok") {
215
- return { kind: "unparseable", commentBody: renderUnparseableNotice(input.locale, input.headSha) };
232
+ return { kind: "unparseable", commentBody: renderUnparseableNotice(input.locale, input.headSha, reviewRan), failClosed: reviewRan };
216
233
  }
217
234
  const verdict = computeVerdict(latest.extraction.findings);
218
235
  const commentBody = renderVerdictComment({
@@ -231,7 +248,19 @@ var realExec = (command, args, stdin) => {
231
248
  const result = spawnSync(command, args, { encoding: "utf-8", input: stdin });
232
249
  return { status: result.status ?? null, stdout: result.stdout ?? "" };
233
250
  };
234
- function runReviewVerdictBin(env, exec) {
251
+ var realWriteOutput = (path, text) => appendFileSync(path, text);
252
+ function outputModePath(env) {
253
+ return env["ARCHWEAVE_VERDICT_OUTPUT"] === "github-output" && env["GITHUB_OUTPUT"] ? env["GITHUB_OUTPUT"] : void 0;
254
+ }
255
+ function renderStepOutputs(commentBody, previousId, blocked) {
256
+ let delimiter;
257
+ do
258
+ delimiter = `ARCHWEAVE_${randomBytes(8).toString("hex")}`;
259
+ while (commentBody.split("\n").includes(delimiter));
260
+ return [`comment-body<<${delimiter}`, commentBody, delimiter, `previous-comment-id=${previousId ?? ""}`, `blocked=${blocked}`, ""].join("\n");
261
+ }
262
+ var UNREAD_COMMENTS_ERROR = "::error title=Review verdict not computed::Could not read PR comments, so the verdict could not be computed. Re-run the failed jobs to retry. If it fails again, check that the review job has pull-requests: read.";
263
+ function runReviewVerdictBin(env, exec, writeOutput = realWriteOutput) {
235
264
  const logs = [];
236
265
  const repo = env["GITHUB_REPOSITORY"];
237
266
  const pr = env["ARCHWEAVE_PR_NUMBER"];
@@ -245,41 +274,54 @@ function runReviewVerdictBin(env, exec) {
245
274
  const roundAfterId = watermark !== void 0 && /^\d+$/.test(watermark) ? Number(watermark) : void 0;
246
275
  const sha = env["ARCHWEAVE_HEAD_SHA"];
247
276
  const headSha = sha !== void 0 && /^[0-9a-f]{7,40}$/i.test(sha) ? sha : void 0;
277
+ const outputPath = outputModePath(env);
248
278
  const fetched = exec("gh", ["api", `repos/${repo}/issues/${pr}/comments`, "--paginate"]);
249
- if (fetched.status !== 0) {
250
- logs.push("verdict gate: could not fetch PR comments via gh \u2014 skipping (check stays green).");
251
- return { exitCode: 0, logs };
252
- }
253
- const pages = splitConcatenatedJson(fetched.stdout);
279
+ const pages = fetched.status === 0 ? splitConcatenatedJson(fetched.stdout) : null;
254
280
  if (pages === null) {
255
- logs.push("verdict gate: unexpected gh output \u2014 skipping (check stays green).");
281
+ if (outputPath !== void 0) return { exitCode: 1, logs: [UNREAD_COMMENTS_ERROR] };
282
+ logs.push(
283
+ fetched.status !== 0 ? "verdict gate: could not fetch PR comments via gh \u2014 skipping (check stays green)." : "verdict gate: unexpected gh output \u2014 skipping (check stays green)."
284
+ );
256
285
  return { exitCode: 0, logs };
257
286
  }
258
287
  const comments = pages.flatMap((page) => Array.isArray(page) ? page : []).filter(
259
288
  (c) => c !== null && typeof c === "object" && typeof c.body === "string" && c.user?.type === "Bot"
260
289
  ).map((c) => ({ body: c.body, id: typeof c.id === "number" ? c.id : void 0 }));
261
- const result = runVerdictGate({ comments, maxRounds, locale, roundAfterId, headSha });
290
+ const reviewRan = outputPath !== void 0 && roundAfterId !== void 0;
291
+ const result = runVerdictGate({ comments, maxRounds, locale, roundAfterId, headSha, reviewRan });
262
292
  if (result.kind === "no-review") {
263
293
  logs.push("verdict gate: no review rounds found on this PR \u2014 nothing to judge.");
264
294
  return { exitCode: 0, logs };
265
295
  }
266
296
  const previous = [...comments].reverse().find((c) => c.body.includes(VERDICT_MARKER) && c.id !== void 0);
267
- const payload = JSON.stringify({ body: result.commentBody });
268
- const updated = previous !== void 0 && exec("gh", ["api", "-X", "PATCH", `repos/${repo}/issues/comments/${previous.id}`, "--input", "-"], payload).status === 0;
269
- if (!updated) {
270
- const post = exec("gh", ["api", "-X", "POST", `repos/${repo}/issues/${pr}/comments`, "--input", "-"], payload);
271
- if (post.status !== 0) logs.push("verdict gate: could not post the verdict comment (continuing).");
297
+ const failedReview = result.kind === "no-summary" || result.kind === "unparseable" && result.failClosed;
298
+ const settled = !failedReview && (result.kind === "no-round" || result.kind === "unparseable");
299
+ const blocked = failedReview || result.kind === "verdict" && result.exitCode !== 0;
300
+ if (outputPath !== void 0) {
301
+ writeOutput(outputPath, renderStepOutputs(result.commentBody, previous?.id, blocked));
302
+ } else {
303
+ const payload = JSON.stringify({ body: result.commentBody });
304
+ const updated = previous !== void 0 && exec("gh", ["api", "-X", "PATCH", `repos/${repo}/issues/comments/${previous.id}`, "--input", "-"], payload).status === 0;
305
+ if (!updated) {
306
+ const post = exec("gh", ["api", "-X", "POST", `repos/${repo}/issues/${pr}/comments`, "--input", "-"], payload);
307
+ if (post.status !== 0) logs.push("verdict gate: could not post the verdict comment (continuing).");
308
+ }
272
309
  }
273
- if (result.kind === "no-round") {
274
- logs.push("verdict gate: this run posted no review round for the current commit \u2014 earlier rounds are not judged again; check stays green.");
310
+ if (failedReview) {
311
+ logs.push(
312
+ result.kind === "no-summary" ? "::error title=AI review posted no summary::The AI review ran but posted no summary for this commit, so there is no verdict. Review the PR manually, or re-run all jobs." : "::error title=AI review summary unreadable::The AI review summary has no readable findings block, so there is no verdict. Review the PR manually, or re-run all jobs."
313
+ );
275
314
  return { exitCode: 0, logs };
276
315
  }
277
- if (result.kind === "unparseable") {
278
- logs.push("verdict gate: review output was not machine-readable \u2014 human review requested, check stays green.");
316
+ if (settled) {
317
+ logs.push(
318
+ result.kind === "no-round" ? "verdict gate: this run posted no review round for the current commit \u2014 earlier rounds are not judged again; check stays green." : "verdict gate: review output was not machine-readable \u2014 human review requested, check stays green."
319
+ );
279
320
  return { exitCode: 0, logs };
280
321
  }
322
+ if (result.kind !== "verdict") return { exitCode: 0, logs };
281
323
  logs.push(`verdict gate: ${result.verdict}`);
282
- return { exitCode: result.exitCode, logs };
324
+ return { exitCode: outputPath === void 0 ? result.exitCode : 0, logs };
283
325
  }
284
326
  function splitConcatenatedJson(stdout) {
285
327
  const text = stdout.trim();