@clearlydev/nx-archweave 0.2.2 → 0.3.1

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
 
@@ -84,7 +84,7 @@ function normalizeFinding(raw) {
84
84
  // ../review-policy/dist/renderers/render-review-comment.js
85
85
  var COPY = {
86
86
  en: {
87
- terminationBanner: (maxRounds) => `> \u26A0\uFE0F **Automated review ended.** The maximum of ${maxRounds} review rounds has been reached with blocking findings (P0/P1) still open. Human review is required.`,
87
+ terminationBanner: (maxRounds) => `> \u26A0\uFE0F **Automated review ended.** The maximum of ${maxRounds} review rounds has been reached with blocking findings (P0/P1) still open. Later pushes aren't reviewed by AI, so review them yourself before merging.`,
88
88
  verdictLines: {
89
89
  BLOCK: "**Verdict: BLOCK** \u2014 P0/P1 findings must be resolved before this change can merge.",
90
90
  CHANGES_RECOMMENDED: "**Verdict: CHANGES_RECOMMENDED** \u2014 P2 findings should be addressed.",
@@ -106,7 +106,7 @@ var COPY = {
106
106
  }
107
107
  },
108
108
  ko: {
109
- terminationBanner: (maxRounds) => `> \u26A0\uFE0F **\uC790\uB3D9 \uB9AC\uBDF0 \uC885\uB8CC.** \uCD5C\uB300 ${maxRounds}\uD68C\uC758 \uB9AC\uBDF0 \uB77C\uC6B4\uB4DC\uC5D0 \uB3C4\uB2EC\uD588\uC9C0\uB9CC \uBCD1\uD569\uC744 \uB9C9\uB294 finding(P0/P1)\uC774 \uB0A8\uC544 \uC788\uC2B5\uB2C8\uB2E4. \uC0AC\uB78C\uC758 \uB9AC\uBDF0\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.`,
109
+ terminationBanner: (maxRounds) => `> \u26A0\uFE0F **\uC790\uB3D9 \uB9AC\uBDF0 \uC885\uB8CC.** \uCD5C\uB300 ${maxRounds}\uD68C\uC758 \uB9AC\uBDF0 \uB77C\uC6B4\uB4DC\uC5D0 \uB3C4\uB2EC\uD588\uC9C0\uB9CC \uBCD1\uD569\uC744 \uB9C9\uB294 finding(P0/P1)\uC774 \uB0A8\uC544 \uC788\uC2B5\uB2C8\uB2E4. \uC774\uD6C4 push\uB294 AI\uAC00 \uB9AC\uBDF0\uD558\uC9C0 \uC54A\uC73C\uB2C8, \uBCD1\uD569 \uC804\uC5D0 \uC9C1\uC811 \uB9AC\uBDF0\uD558\uC138\uC694.`,
110
110
  verdictLines: {
111
111
  BLOCK: "**\uD310\uC815: BLOCK** \u2014 \uBCD1\uD569 \uC804\uC5D0 P0/P1 finding\uC744 \uD574\uACB0\uD574\uC57C \uD569\uB2C8\uB2E4.",
112
112
  CHANGES_RECOMMENDED: "**\uD310\uC815: CHANGES_RECOMMENDED** \u2014 P2 finding\uC744 \uD574\uACB0\uD558\uB294 \uAC83\uC744 \uAD8C\uC7A5\uD569\uB2C8\uB2E4.",
@@ -150,15 +150,29 @@ var NO_ROUND_NOTICE = {
150
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.",
151
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."
152
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
+ }
153
164
  var HEAD_LINE = {
154
- en: (sha) => `<sub>Commit \`${sha.slice(0, 7)}\` \xB7 this comment is updated on every review round.</sub>`,
155
- 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))
156
167
  };
157
168
  function withHead(lines, locale, headSha) {
158
169
  return (headSha === void 0 ? lines : [...lines, "", HEAD_LINE[locale](headSha)]).join("\n");
159
170
  }
160
- function renderUnparseableNotice(locale, headSha) {
161
- 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);
162
176
  }
163
177
  function renderNoRoundNotice(locale, headSha) {
164
178
  return withHead([VERDICT_MARKER, NO_ROUND_NOTICE[locale]], locale, headSha);
@@ -186,6 +200,14 @@ var ROUND_MARKER = "<!-- archweave-review-round -->";
186
200
  function collectRounds(comments) {
187
201
  return comments.filter((c) => c.body.includes(ROUND_MARKER) && !c.body.includes(VERDICT_MARKER)).map((c, index) => ({ round: index + 1, id: c.id, extraction: extractFindingsBlock(c.body) }));
188
202
  }
203
+ var LINE_DRIFT = 3;
204
+ function sameDefect(a, b) {
205
+ if (a.file !== b.file)
206
+ return false;
207
+ const aEnd = a.lineRange.end ?? a.lineRange.start;
208
+ const bEnd = b.lineRange.end ?? b.lineRange.start;
209
+ return a.lineRange.start <= bEnd + LINE_DRIFT && b.lineRange.start <= aEnd + LINE_DRIFT;
210
+ }
189
211
  function computeRoundHistory(rounds) {
190
212
  const parsed = rounds.filter((r) => r.extraction.status === "ok");
191
213
  const history = [];
@@ -195,10 +217,10 @@ function computeRoundHistory(rounds) {
195
217
  history.push({ round: parsed[i].round, fixedFindingKeys: [], remainingFindingKeys: [...keys] });
196
218
  continue;
197
219
  }
198
- const previous = new Set(parsed[i - 1].extraction.findings.map(findingKey));
220
+ const current = parsed[i].extraction.findings;
199
221
  history.push({
200
222
  round: parsed[i].round,
201
- fixedFindingKeys: [...previous].filter((key) => !keys.has(key)),
223
+ fixedFindingKeys: parsed[i - 1].extraction.findings.filter((finding) => !current.some((now) => sameDefect(finding, now))).map(findingKey),
202
224
  remainingFindingKeys: [...keys]
203
225
  });
204
226
  }
@@ -206,14 +228,16 @@ function computeRoundHistory(rounds) {
206
228
  }
207
229
  function runVerdictGate(input) {
208
230
  const rounds = collectRounds(input.comments);
209
- if (rounds.length === 0)
210
- return { kind: "no-review" };
231
+ const reviewRan = input.reviewRan === true;
232
+ if (rounds.length === 0) {
233
+ return reviewRan ? { kind: "no-summary", commentBody: renderNoSummaryNotice(input.locale, input.headSha) } : { kind: "no-review" };
234
+ }
211
235
  const latest = rounds[rounds.length - 1];
212
236
  if (input.roundAfterId !== void 0 && (latest.id === void 0 || latest.id <= input.roundAfterId)) {
213
- return { kind: "no-round", commentBody: renderNoRoundNotice(input.locale, input.headSha) };
237
+ return reviewRan ? { kind: "no-summary", commentBody: renderNoSummaryNotice(input.locale, input.headSha) } : { kind: "no-round", commentBody: renderNoRoundNotice(input.locale, input.headSha) };
214
238
  }
215
239
  if (latest.extraction.status !== "ok") {
216
- return { kind: "unparseable", commentBody: renderUnparseableNotice(input.locale, input.headSha) };
240
+ return { kind: "unparseable", commentBody: renderUnparseableNotice(input.locale, input.headSha, reviewRan), failClosed: reviewRan };
217
241
  }
218
242
  const verdict = computeVerdict(latest.extraction.findings);
219
243
  const commentBody = renderVerdictComment({
@@ -236,12 +260,20 @@ var realWriteOutput = (path, text) => appendFileSync(path, text);
236
260
  function outputModePath(env) {
237
261
  return env["ARCHWEAVE_VERDICT_OUTPUT"] === "github-output" && env["GITHUB_OUTPUT"] ? env["GITHUB_OUTPUT"] : void 0;
238
262
  }
239
- function renderStepOutputs(commentBody, previousId, blocked) {
263
+ function renderStepOutputs(commentBody, previousId, blocked, reason) {
240
264
  let delimiter;
241
265
  do
242
266
  delimiter = `ARCHWEAVE_${randomBytes(8).toString("hex")}`;
243
267
  while (commentBody.split("\n").includes(delimiter));
244
- return [`comment-body<<${delimiter}`, commentBody, delimiter, `previous-comment-id=${previousId ?? ""}`, `blocked=${blocked}`, ""].join("\n");
268
+ return [
269
+ `comment-body<<${delimiter}`,
270
+ commentBody,
271
+ delimiter,
272
+ `previous-comment-id=${previousId ?? ""}`,
273
+ `blocked=${blocked}`,
274
+ `block-reason=${blocked ? reason ?? "" : ""}`,
275
+ ""
276
+ ].join("\n");
245
277
  }
246
278
  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.";
247
279
  function runReviewVerdictBin(env, exec, writeOutput = realWriteOutput) {
@@ -271,15 +303,19 @@ function runReviewVerdictBin(env, exec, writeOutput = realWriteOutput) {
271
303
  const comments = pages.flatMap((page) => Array.isArray(page) ? page : []).filter(
272
304
  (c) => c !== null && typeof c === "object" && typeof c.body === "string" && c.user?.type === "Bot"
273
305
  ).map((c) => ({ body: c.body, id: typeof c.id === "number" ? c.id : void 0 }));
274
- const result = runVerdictGate({ comments, maxRounds, locale, roundAfterId, headSha });
306
+ const reviewRan = outputPath !== void 0 && roundAfterId !== void 0;
307
+ const result = runVerdictGate({ comments, maxRounds, locale, roundAfterId, headSha, reviewRan });
275
308
  if (result.kind === "no-review") {
276
309
  logs.push("verdict gate: no review rounds found on this PR \u2014 nothing to judge.");
277
310
  return { exitCode: 0, logs };
278
311
  }
279
312
  const previous = [...comments].reverse().find((c) => c.body.includes(VERDICT_MARKER) && c.id !== void 0);
280
- const settled = result.kind === "no-round" || result.kind === "unparseable";
313
+ const failedReview = result.kind === "no-summary" || result.kind === "unparseable" && result.failClosed;
314
+ const settled = !failedReview && (result.kind === "no-round" || result.kind === "unparseable");
315
+ const blocked = failedReview || result.kind === "verdict" && result.exitCode !== 0;
281
316
  if (outputPath !== void 0) {
282
- writeOutput(outputPath, renderStepOutputs(result.commentBody, previous?.id, !settled && result.exitCode !== 0));
317
+ const reason = result.kind === "no-summary" ? "no-summary" : result.kind === "unparseable" ? "unreadable" : "block";
318
+ writeOutput(outputPath, renderStepOutputs(result.commentBody, previous?.id, blocked, reason));
283
319
  } else {
284
320
  const payload = JSON.stringify({ body: result.commentBody });
285
321
  const updated = previous !== void 0 && exec("gh", ["api", "-X", "PATCH", `repos/${repo}/issues/comments/${previous.id}`, "--input", "-"], payload).status === 0;
@@ -288,12 +324,19 @@ function runReviewVerdictBin(env, exec, writeOutput = realWriteOutput) {
288
324
  if (post.status !== 0) logs.push("verdict gate: could not post the verdict comment (continuing).");
289
325
  }
290
326
  }
327
+ if (failedReview) {
328
+ logs.push(
329
+ 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."
330
+ );
331
+ return { exitCode: 0, logs };
332
+ }
291
333
  if (settled) {
292
334
  logs.push(
293
335
  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."
294
336
  );
295
337
  return { exitCode: 0, logs };
296
338
  }
339
+ if (result.kind !== "verdict") return { exitCode: 0, logs };
297
340
  logs.push(`verdict gate: ${result.verdict}`);
298
341
  return { exitCode: outputPath === void 0 ? result.exitCode : 0, logs };
299
342
  }