@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.
@@ -288,6 +288,30 @@ function buildOverEngineeringPrompt(locale) {
288
288
  return [...body, "", renderFindingSchemaInstruction(locale, OVER_ENGINEERING_PRIORITIES), "", renderDedupInstruction(locale)].join("\n");
289
289
  }
290
290
 
291
+ // ../review-policy/dist/verdict-gate/verdict-comment.js
292
+ var VERDICT_MARKER = "<!-- archweave-verdict -->";
293
+ function renderCommitLine(locale, shortSha) {
294
+ 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>`;
295
+ }
296
+ var NOT_RUN_REASON = {
297
+ en: {
298
+ secret: (secretName) => `the ${secretName} secret is not registered`,
299
+ fork: () => "this pull request comes from a fork, and GitHub passes no secrets to runs from forks",
300
+ dependabot: (secretName) => `Dependabot runs receive only Dependabot secrets, and ${secretName} is not one of them`,
301
+ rounds: (_secretName, maxRounds) => `the automated review round limit (${maxRounds}) is reached`
302
+ },
303
+ ko: {
304
+ secret: (secretName) => `${secretName} \uC2DC\uD06C\uB9BF\uC774 \uB4F1\uB85D\uB418\uC9C0 \uC54A\uC74C`,
305
+ fork: () => "\uD3EC\uD06C\uC5D0\uC11C \uC628 pull request\uC774\uACE0, GitHub\uB294 \uD3EC\uD06C \uC2E4\uD589\uC5D0 \uC2DC\uD06C\uB9BF\uC744 \uB118\uAE30\uC9C0 \uC54A\uC74C",
306
+ dependabot: (secretName) => `Dependabot \uC2E4\uD589\uC740 Dependabot \uC2DC\uD06C\uB9BF\uB9CC \uBC1B\uB294\uB370, ${secretName}\uB294 \uADF8\uC911\uC5D0 \uC5C6\uC74C`,
307
+ rounds: (_secretName, maxRounds) => `\uC790\uB3D9 \uB9AC\uBDF0 \uB77C\uC6B4\uB4DC \uD55C\uB3C4(${maxRounds})\uC5D0 \uB3C4\uB2EC\uD568`
308
+ }
309
+ };
310
+ function renderNotRunNotice(locale, reason, secretName, maxRounds) {
311
+ const why = NOT_RUN_REASON[locale][reason](secretName, maxRounds);
312
+ return locale === "en" ? `AI review did not run for this commit (${why}). A green check here does not mean the change was reviewed.` : `\uC774 \uCEE4\uBC0B\uC5D0\uB294 AI \uB9AC\uBDF0\uAC00 \uC2E4\uD589\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4(${why}). \uC774 green \uCCB4\uD06C\uB294 \uBCC0\uACBD \uC0AC\uD56D\uC774 \uB9AC\uBDF0\uB418\uC5C8\uB2E4\uB294 \uB73B\uC774 \uC544\uB2D9\uB2C8\uB2E4.`;
313
+ }
314
+
291
315
  // ../review-policy/dist/verdict-gate/verdict-gate.js
292
316
  var ROUND_MARKER = "<!-- archweave-review-round -->";
293
317
 
@@ -320,13 +344,14 @@ function renderConcurrencyBlock() {
320
344
  " cancel-in-progress: false"
321
345
  ].join("\n");
322
346
  }
323
- function renderPermissionsBlock() {
324
- return ["permissions:", " contents: read", " pull-requests: write", " id-token: write"].join("\n");
347
+ var REVIEW_GATE_CONDITION = "steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'";
348
+ function renderReviewPermissions(idToken) {
349
+ return ["permissions:", " contents: read", " pull-requests: write", ...idToken ? [" id-token: write"] : []].join("\n");
325
350
  }
326
351
  function renderCheckoutStep(gateCondition, extraWith = []) {
327
352
  return [
328
353
  "- name: Checkout repository",
329
- ` if: ${gateCondition}`,
354
+ ...gateCondition === void 0 ? [] : [` if: ${gateCondition}`],
330
355
  ` uses: ${pinnedUses("actions/checkout")}`,
331
356
  " with:",
332
357
  ...extraWith.map((line) => ` ${line}`),
@@ -368,10 +393,84 @@ function renderRoundGateStep(maxRounds) {
368
393
  " fi"
369
394
  ].join("\n");
370
395
  }
396
+ function shellQuote(text) {
397
+ return `'${text.replace(/'/g, `'\\''`)}'`;
398
+ }
399
+ function renderNotRunFunction(secretName, maxRounds, locale) {
400
+ const reasons = ["secret", "fork", "dependabot", "rounds"];
401
+ const [commitBefore, commitAfter] = renderCommitLine(locale, "\0").split("\0");
402
+ return [
403
+ "not_run() {",
404
+ ' case "$1" in',
405
+ ...reasons.map((reason) => ` ${reason}) annotation=${shellQuote(renderNotRunNotice("en", reason, secretName, maxRounds))}; notice=${shellQuote(renderNotRunNotice(locale, reason, secretName, maxRounds))} ;;`),
406
+ " esac",
407
+ ' echo "::notice title=AI review did not run::$annotation"',
408
+ ` printf '%s\\n' "$notice" >> "$GITHUB_STEP_SUMMARY"`,
409
+ ` short_sha=$(printf '%s' "$HEAD_SHA" | cut -c1-7)`,
410
+ ' case "$short_sha" in',
411
+ " '' | *[!0-9a-f]*) short_sha= ;;",
412
+ " esac",
413
+ " {",
414
+ ' echo "notice-body<<ARCHWEAVE_NOT_RUN_EOF"',
415
+ ` printf '%s\\n' ${shellQuote(VERDICT_MARKER)} "\u2139\uFE0F $notice"`,
416
+ ` if [ -n "$short_sha" ]; then printf '\\n%s\\n' ${shellQuote(commitBefore)}"$short_sha"${shellQuote(commitAfter)}; fi`,
417
+ ' echo "ARCHWEAVE_NOT_RUN_EOF"',
418
+ ' echo "run=false"',
419
+ ' } >> "$GITHUB_OUTPUT"',
420
+ "}"
421
+ ];
422
+ }
423
+ var JUDGE_CONDITION = "steps.ai-review-result.outputs.run == 'true'";
424
+ function renderAiReviewResultStep(secretName, maxRounds, locale) {
425
+ return [
426
+ "- name: Check the AI review result",
427
+ " id: ai-review-result",
428
+ " env:",
429
+ " AI_REVIEW_RESULT: ${{ needs.ai-review.result }}",
430
+ " REVIEW_ENABLED: ${{ needs.ai-review.outputs.enabled }}",
431
+ " REVIEW_ALLOWED: ${{ needs.ai-review.outputs.allowed }}",
432
+ " LAST_COMMENT_ID: ${{ needs.ai-review.outputs.last-comment-id }}",
433
+ " HEAD_SHA: ${{ github.event.pull_request.head.sha }}",
434
+ " HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}",
435
+ " REPOSITORY: ${{ github.repository }}",
436
+ " PR_AUTHOR: ${{ github.event.pull_request.user.login }}",
437
+ " run: |",
438
+ ...renderNotRunFunction(secretName, maxRounds, locale).map((line) => ` ${line}`),
439
+ ' if [ "$AI_REVIEW_RESULT" != success ]; then',
440
+ ` echo "::error title=AI review did not succeed::The AI review job ended with '$AI_REVIEW_RESULT'."`,
441
+ " exit 1",
442
+ " fi",
443
+ ' case "$REVIEW_ENABLED/$REVIEW_ALLOWED" in',
444
+ " false/*)",
445
+ ' if [ "$HEAD_REPOSITORY" != "$REPOSITORY" ]; then not_run fork',
446
+ ` elif [ "$PR_AUTHOR" = 'dependabot[bot]' ]; then not_run dependabot`,
447
+ " else not_run secret",
448
+ " fi",
449
+ " exit 0",
450
+ " ;;",
451
+ " true/false)",
452
+ " not_run rounds",
453
+ " exit 0",
454
+ " ;;",
455
+ " true/true)",
456
+ ' case "$LAST_COMMENT_ID" in',
457
+ " '' | *[!0-9]*) ;;",
458
+ " *)",
459
+ ' echo "run=true" >> "$GITHUB_OUTPUT"',
460
+ " exit 0",
461
+ " ;;",
462
+ " esac",
463
+ " ;;",
464
+ " esac",
465
+ ` echo "::error title=Review verdict not computed::The AI review job's results did not reach this job, so no verdict can be computed. Re-run all jobs."`,
466
+ " exit 1"
467
+ ].join("\n");
468
+ }
371
469
  function renderVerdictGateStep(maxRounds, locale) {
372
470
  return [
373
471
  "- name: Compute review verdict",
374
- " if: steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'",
472
+ " id: verdict",
473
+ ` if: ${JUDGE_CONDITION}`,
375
474
  " env:",
376
475
  " GH_TOKEN: ${{ github.token }}",
377
476
  " GITHUB_REPOSITORY: ${{ github.repository }}",
@@ -379,7 +478,8 @@ function renderVerdictGateStep(maxRounds, locale) {
379
478
  ` ARCHWEAVE_MAX_ROUNDS: '${maxRounds}'`,
380
479
  ` ARCHWEAVE_LOCALE: '${locale}'`,
381
480
  " ARCHWEAVE_HEAD_SHA: ${{ github.event.pull_request.head.sha }}",
382
- " ARCHWEAVE_ROUND_AFTER_ID: ${{ steps.round-gate.outputs.last-comment-id }}",
481
+ " ARCHWEAVE_ROUND_AFTER_ID: ${{ needs.ai-review.outputs.last-comment-id }}",
482
+ " ARCHWEAVE_VERDICT_OUTPUT: 'github-output'",
383
483
  " run: |",
384
484
  " if [ -x node_modules/.bin/archweave-review-verdict ]; then",
385
485
  " node_modules/.bin/archweave-review-verdict",
@@ -388,16 +488,49 @@ function renderVerdictGateStep(maxRounds, locale) {
388
488
  " fi"
389
489
  ].join("\n");
390
490
  }
491
+ function renderBlockStep() {
492
+ return [
493
+ "- name: Fail on a blocking verdict",
494
+ " if: steps.verdict.outputs.blocked == 'true'",
495
+ " run: |",
496
+ ' echo "The review check failed: the verdict is BLOCK, or the AI review left no readable summary. See the verdict comment on this pull request."',
497
+ " exit 1"
498
+ ].join("\n");
499
+ }
500
+ function renderVerdictCommentStep() {
501
+ return [
502
+ "- name: Post review verdict",
503
+ " env:",
504
+ " GH_TOKEN: ${{ github.token }}",
505
+ " REPOSITORY: ${{ github.repository }}",
506
+ " PR_NUMBER: ${{ github.event.pull_request.number }}",
507
+ " VERDICT_BODY: ${{ needs.review.outputs.comment-body }}",
508
+ " PREVIOUS_ID: ${{ needs.review.outputs.previous-comment-id }}",
509
+ " run: |",
510
+ ` payload=$(jq -n --arg body "$VERDICT_BODY" '{body: $body}')`,
511
+ ' if [ -z "$PREVIOUS_ID" ]; then',
512
+ ` PREVIOUS_ID=$(gh api "repos/$REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("${VERDICT_MARKER}")) | .id' | tail -n 1) || PREVIOUS_ID=`,
513
+ " fi",
514
+ ' case "$PREVIOUS_ID" in',
515
+ " ''|*[!0-9]*) PREVIOUS_ID= ;;",
516
+ " esac",
517
+ ` if [ -n "$PREVIOUS_ID" ] && printf '%s' "$payload" | gh api -X PATCH "repos/$REPOSITORY/issues/comments/$PREVIOUS_ID" --input - > /dev/null; then`,
518
+ " exit 0",
519
+ " fi",
520
+ ` printf '%s' "$payload" | gh api -X POST "repos/$REPOSITORY/issues/$PR_NUMBER/comments" --input - > /dev/null \\`,
521
+ ' || echo "Could not post the verdict comment (continuing)."'
522
+ ].join("\n");
523
+ }
391
524
  var SETUP_NODE_VERSION = "22";
392
525
  var FALLBACK_PNPM_VERSION = "10";
393
526
  var INSTALL_LINES = {
394
- pnpm: [" pnpm install --frozen-lockfile"],
395
- npm: [" npm ci"],
527
+ pnpm: [" pnpm install --frozen-lockfile --ignore-scripts"],
528
+ npm: [" npm ci --ignore-scripts"],
396
529
  yarn: [
397
530
  " corepack enable yarn",
398
- ' if [ "$(yarn --version | cut -d. -f1)" -ge 2 ]; then yarn install --immutable; else yarn install --frozen-lockfile; fi'
531
+ ' if [ "$(yarn --version | cut -d. -f1)" -ge 2 ]; then YARN_ENABLE_SCRIPTS=false yarn install --immutable --mode=skip-build; else yarn install --frozen-lockfile --ignore-scripts; fi'
399
532
  ],
400
- bun: [" bun install --frozen-lockfile"]
533
+ bun: [" bun install --frozen-lockfile --ignore-scripts"]
401
534
  };
402
535
  function renderPnpmVersionStep(gate) {
403
536
  return [
@@ -413,7 +546,7 @@ function renderPnpmVersionStep(gate) {
413
546
  ].join("\n");
414
547
  }
415
548
  function renderWorkspaceSetupSteps(packageManager) {
416
- const gate = " if: steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'";
549
+ const gate = ` if: ${JUDGE_CONDITION}`;
417
550
  const steps = [];
418
551
  if (packageManager === "pnpm") {
419
552
  steps.push(renderPnpmVersionStep(gate));
@@ -448,21 +581,89 @@ function renderWorkspaceSetupSteps(packageManager) {
448
581
  ].join("\n"));
449
582
  return steps.join("\n\n");
450
583
  }
584
+ var REVIEW_JOB_TIMEOUT_MINUTES = 15;
585
+ function renderReviewWorkflow(input, parts) {
586
+ const aiReviewSteps = [renderSecretGateStep(parts.secretName), renderRoundGateStep(input.maxRounds), ...parts.reviewSteps];
587
+ const reviewSteps = [
588
+ renderAiReviewResultStep(parts.secretName, input.maxRounds, input.locale),
589
+ renderCheckoutStep(JUDGE_CONDITION),
590
+ renderWorkspaceSetupSteps(input.packageManager),
591
+ renderVerdictGateStep(input.maxRounds, input.locale),
592
+ renderBlockStep()
593
+ ];
594
+ return [
595
+ `name: ${parts.name}`,
596
+ "",
597
+ renderTriggerBlock(),
598
+ "",
599
+ renderConcurrencyBlock(),
600
+ "",
601
+ "permissions: {}",
602
+ "",
603
+ "jobs:",
604
+ " ai-review:",
605
+ " runs-on: ubuntu-latest",
606
+ ` timeout-minutes: ${REVIEW_JOB_TIMEOUT_MINUTES}`,
607
+ indentBlock(renderReviewPermissions(parts.idToken), 4),
608
+ " outputs:",
609
+ " enabled: ${{ steps.secret-gate.outputs.enabled }}",
610
+ " allowed: ${{ steps.round-gate.outputs.allowed }}",
611
+ " last-comment-id: ${{ steps.round-gate.outputs.last-comment-id }}",
612
+ " steps:",
613
+ indentBlock(aiReviewSteps.join("\n\n"), 6),
614
+ "",
615
+ " review:",
616
+ " needs: ai-review",
617
+ " if: always()",
618
+ " runs-on: ubuntu-latest",
619
+ ` timeout-minutes: ${REVIEW_JOB_TIMEOUT_MINUTES}`,
620
+ " permissions:",
621
+ " contents: read",
622
+ " pull-requests: read",
623
+ " outputs:",
624
+ " comment-body: ${{ steps.verdict.outputs.comment-body || steps.ai-review-result.outputs.notice-body }}",
625
+ " previous-comment-id: ${{ steps.verdict.outputs.previous-comment-id }}",
626
+ " steps:",
627
+ indentBlock(reviewSteps.join("\n\n"), 6),
628
+ "",
629
+ " review-comment:",
630
+ " needs: review",
631
+ // Runs after a BLOCK failed the review job, too: the comment explains the red check.
632
+ " if: ${{ !cancelled() && needs.review.outputs.comment-body != '' }}",
633
+ " runs-on: ubuntu-latest",
634
+ ` timeout-minutes: ${REVIEW_JOB_TIMEOUT_MINUTES}`,
635
+ " permissions:",
636
+ " pull-requests: write",
637
+ " steps:",
638
+ indentBlock(renderVerdictCommentStep(), 6),
639
+ ""
640
+ ].join("\n");
641
+ }
451
642
 
452
643
  // ../template-engine/dist/review/claude/claude-provider.js
453
644
  var SECRET_NAME = "CLAUDE_CODE_OAUTH_TOKEN";
454
645
  var ALLOWED_TOOLS = "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)";
646
+ var CLAUDE_STEP_ID = "claude-review";
647
+ var PR_CONTEXT = ["REPO: ${{ github.repository }}", "PR NUMBER: ${{ github.event.pull_request.number }}"];
455
648
  function renderWorkflowInstructions(locale) {
456
649
  const lines = locale === "en" ? [
457
- "Review the diff of this pull request using `gh pr diff` and `gh pr view`.",
458
- "Post specific findings as inline comments and exactly one top-level summary comment.",
650
+ ...PR_CONTEXT,
651
+ "",
652
+ "Review the diff of this pull request using `gh pr diff <PR NUMBER>` and `gh pr view <PR NUMBER>`.",
653
+ "Post specific findings as inline comments with `mcp__github_inline_comment__create_inline_comment` (pass `confirmed: true`), and exactly one top-level summary comment.",
654
+ "Post the summary with `gh pr comment <PR NUMBER> --body-file - <<'ARCHWEAVE_EOF'`, the body on the following lines, then a line `ARCHWEAVE_EOF` (the quoted delimiter keeps backticks and `$` literal). Never pass the body with `--body \"...\"` or `$(...)`.",
655
+ "Run each `gh` command on its own: no pipes to other programs, no `&&` chains.",
459
656
  `Always include the literal marker ${ROUND_MARKER} in the top-level summary comment.`,
460
657
  "If there are no findings that require action, post exactly one top-level comment saying so (and still include the marker).",
461
658
  "Do not modify any code, and never approve or merge the pull request.",
462
659
  "Write summaries and explanations in English; keep file names, function names, and identifiers verbatim."
463
660
  ] : [
464
- "\uC774 pull request\uC758 diff\uB97C `gh pr diff`\uC640 `gh pr view`\uB85C \uD655\uC778\uD558\uACE0 \uB9AC\uBDF0\uD558\uC138\uC694.",
465
- "\uAD6C\uCCB4\uC801 \uC9C0\uC801\uC740 inline comment\uB85C, \uC804\uCCB4 \uC694\uC57D\uC740 \uC815\uD655\uD788 \uD55C \uAC1C\uC758 top-level comment\uB85C \uAC8C\uC2DC\uD558\uC138\uC694.",
661
+ ...PR_CONTEXT,
662
+ "",
663
+ "\uC774 pull request\uC758 diff\uB97C `gh pr diff <PR NUMBER>`\uC640 `gh pr view <PR NUMBER>`\uB85C \uD655\uC778\uD558\uACE0 \uB9AC\uBDF0\uD558\uC138\uC694.",
664
+ "\uAD6C\uCCB4\uC801 \uC9C0\uC801\uC740 `mcp__github_inline_comment__create_inline_comment`(`confirmed: true` \uC804\uB2EC)\uB85C inline comment\uB97C \uB2EC\uACE0, \uC804\uCCB4 \uC694\uC57D\uC740 \uC815\uD655\uD788 \uD55C \uAC1C\uC758 top-level comment\uB85C \uAC8C\uC2DC\uD558\uC138\uC694.",
665
+ "\uC694\uC57D\uC740 `gh pr comment <PR NUMBER> --body-file - <<'ARCHWEAVE_EOF'`\uB85C \uAC8C\uC2DC\uD558\uC138\uC694. \uB2E4\uC74C \uC904\uBD80\uD130 \uBCF8\uBB38\uC744 \uC4F0\uACE0, \uB9C8\uC9C0\uB9C9 \uC904\uC5D0 `ARCHWEAVE_EOF`\uB9CC \uC4F0\uC138\uC694(\uB530\uC634\uD45C \uCE5C \uAD6C\uBD84\uC790\uB77C \uBC31\uD2F1\uACFC `$`\uAC00 \uADF8\uB300\uB85C \uB0A8\uC544\uC694). \uBCF8\uBB38\uC744 `--body \"...\"`\uB098 `$(...)`\uB85C \uB118\uAE30\uC9C0 \uB9C8\uC138\uC694.",
666
+ "`gh` \uBA85\uB839\uC740 \uD558\uB098\uC529 \uB530\uB85C \uC2E4\uD589\uD558\uC138\uC694. \uB2E4\uB978 \uD504\uB85C\uADF8\uB7A8\uC73C\uB85C \uD30C\uC774\uD504\uD558\uAC70\uB098 `&&`\uB85C \uC787\uC9C0 \uB9C8\uC138\uC694.",
466
667
  `top-level \uC694\uC57D \uCF54\uBA58\uD2B8\uC5D0\uB294 \uBC18\uB4DC\uC2DC \uB9AC\uD130\uB7F4 \uB9C8\uCEE4 ${ROUND_MARKER}\uB97C \uD3EC\uD568\uD558\uC138\uC694.`,
467
668
  '\uC870\uCE58\uAC00 \uD544\uC694\uD55C \uBB38\uC81C\uAC00 \uC5C6\uC73C\uBA74 "\uC870\uCE58\uAC00 \uD544\uC694\uD55C \uBB38\uC81C\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4."\uB77C\uB294 top-level comment\uB97C \uC815\uD655\uD788 \uD55C \uAC1C \uAC8C\uC2DC\uD558\uC138\uC694(\uC774\uB54C\uB3C4 \uB9C8\uCEE4\uB294 \uD3EC\uD568).',
468
669
  "\uCF54\uB4DC\uB97C \uC218\uC815\uD558\uC9C0 \uB9C8\uC138\uC694(Do not modify any code). PR\uC744 \uC2B9\uC778\uD558\uAC70\uB098 \uBCD1\uD569\uD558\uC9C0 \uB9C8\uC138\uC694(never approve or merge).",
@@ -479,42 +680,44 @@ function composePrompt(input) {
479
680
  sections.push(renderFindingsBlockInstruction(input.locale));
480
681
  return sections.join("\n\n---\n\n");
481
682
  }
482
- function buildWorkflowYaml(input) {
483
- const steps = [
484
- renderSecretGateStep(SECRET_NAME),
485
- renderRoundGateStep(input.maxRounds),
486
- renderCheckoutStep("steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'"),
487
- [
488
- "- name: Run Claude review",
489
- " if: steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'",
490
- ` uses: ${pinnedUses("anthropics/claude-code-action")}`,
491
- " with:",
492
- ` claude_code_oauth_token: \${{ secrets.${SECRET_NAME} }}`,
493
- " prompt: |",
494
- indentBlock(composePrompt(input), 6),
495
- " claude_args: |",
496
- ` --allowedTools "${ALLOWED_TOOLS}"`
497
- ].join("\n"),
498
- renderWorkspaceSetupSteps(input.packageManager),
499
- renderVerdictGateStep(input.maxRounds, input.locale)
500
- ];
683
+ function renderDeniedToolsStep() {
501
684
  return [
502
- "name: Claude Code Review",
503
- "",
504
- renderTriggerBlock(),
505
- "",
506
- renderConcurrencyBlock(),
507
- "",
508
- renderPermissionsBlock(),
509
- "",
510
- "jobs:",
511
- " review:",
512
- " runs-on: ubuntu-latest",
513
- " steps:",
514
- indentBlock(steps.join("\n\n"), 6),
515
- ""
685
+ "- name: Show denied tool calls",
686
+ ` if: always() && steps.${CLAUDE_STEP_ID}.outputs.execution_file != ''`,
687
+ " env:",
688
+ ` EXECUTION_FILE: \${{ steps.${CLAUDE_STEP_ID}.outputs.execution_file }}`,
689
+ " run: |",
690
+ ' [ -f "$EXECUTION_FILE" ] || exit 0',
691
+ ` stop_token="archweave-$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')"`,
692
+ ' echo "::stop-commands::$stop_token"',
693
+ ` jq -c '.[] | select(.type == "result") | .permission_denials[]? | {tool: .tool_name, command: ((.tool_input.command // "") | tostring | .[0:200])}' "$EXECUTION_FILE" || echo "Could not read the execution file."`,
694
+ ' echo "::$stop_token::"'
516
695
  ].join("\n");
517
696
  }
697
+ function buildWorkflowYaml(input) {
698
+ return renderReviewWorkflow(input, {
699
+ name: "Claude Code Review",
700
+ secretName: SECRET_NAME,
701
+ // The Claude GitHub App signs in with the workflow's OIDC token (claude-code-action's FAQ).
702
+ idToken: true,
703
+ reviewSteps: [
704
+ renderCheckoutStep(REVIEW_GATE_CONDITION),
705
+ [
706
+ "- name: Run Claude review",
707
+ ` id: ${CLAUDE_STEP_ID}`,
708
+ ` if: ${REVIEW_GATE_CONDITION}`,
709
+ ` uses: ${pinnedUses("anthropics/claude-code-action")}`,
710
+ " with:",
711
+ ` claude_code_oauth_token: \${{ secrets.${SECRET_NAME} }}`,
712
+ " prompt: |",
713
+ indentBlock(composePrompt(input), 6),
714
+ " claude_args: |",
715
+ ` --allowedTools "${ALLOWED_TOOLS}"`
716
+ ].join("\n"),
717
+ renderDeniedToolsStep()
718
+ ]
719
+ });
720
+ }
518
721
  var claudeProvider = {
519
722
  id: "claude",
520
723
  workflowFileName: ".github/workflows/claude-code-review.yml",
@@ -553,49 +756,41 @@ function composePrompt2(input) {
553
756
  return sections.join("\n\n---\n\n");
554
757
  }
555
758
  function buildWorkflowYaml2(input) {
556
- const gateCondition = "steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'";
557
- const steps = [
558
- renderSecretGateStep(SECRET_NAME2),
559
- renderRoundGateStep(input.maxRounds),
560
- renderCheckoutStep(gateCondition),
561
- [
562
- "- name: Install cursor-agent CLI",
563
- ` if: ${gateCondition}`,
564
- " run: |",
565
- " curl https://cursor.com/install -fsS | bash",
566
- ' echo "$HOME/.cursor/bin" >> "$GITHUB_PATH"'
567
- ].join("\n"),
568
- [
569
- "- name: Run Cursor review",
570
- ` if: ${gateCondition}`,
571
- " env:",
572
- ` CURSOR_API_KEY: \${{ secrets.${SECRET_NAME2} }}`,
573
- " GH_TOKEN: ${{ github.token }}",
574
- " run: |",
575
- ` agent -p "$(cat <<'${PROMPT_HEREDOC_DELIMITER}'`,
576
- indentBlock(composePrompt2(input), 4),
577
- ` ${PROMPT_HEREDOC_DELIMITER}`,
578
- ' )"'
579
- ].join("\n"),
580
- renderWorkspaceSetupSteps(input.packageManager),
581
- renderVerdictGateStep(input.maxRounds, input.locale)
582
- ];
583
- return [
584
- "name: Cursor Code Review",
585
- "",
586
- renderTriggerBlock(),
587
- "",
588
- renderConcurrencyBlock(),
589
- "",
590
- renderPermissionsBlock(),
591
- "",
592
- "jobs:",
593
- " review:",
594
- " runs-on: ubuntu-latest",
595
- " steps:",
596
- indentBlock(steps.join("\n\n"), 6),
597
- ""
598
- ].join("\n");
759
+ return renderReviewWorkflow(input, {
760
+ name: "Cursor Code Review",
761
+ secretName: SECRET_NAME2,
762
+ // An API key: the Cursor CLI needs no OIDC token.
763
+ idToken: false,
764
+ reviewSteps: [
765
+ renderCheckoutStep(REVIEW_GATE_CONDITION),
766
+ [
767
+ "- name: Install cursor-agent CLI",
768
+ ` if: ${REVIEW_GATE_CONDITION}`,
769
+ " run: |",
770
+ // HTTPS only, TLS 1.2 or newer. Cursor publishes no pinned version or checksum for
771
+ // this installer; docs/pr-review.md states what that leaves open.
772
+ " curl --proto '=https' --tlsv1.2 -fsS https://cursor.com/install | bash",
773
+ // The docs name ~/.cursor/bin; the installer currently links `agent` into ~/.local/bin.
774
+ // Both go on the PATH, so the next step finds it wherever the installer puts it.
775
+ ' echo "$HOME/.local/bin" >> "$GITHUB_PATH"',
776
+ ' echo "$HOME/.cursor/bin" >> "$GITHUB_PATH"'
777
+ ].join("\n"),
778
+ [
779
+ "- name: Run Cursor review",
780
+ ` if: ${REVIEW_GATE_CONDITION}`,
781
+ " env:",
782
+ ` CURSOR_API_KEY: \${{ secrets.${SECRET_NAME2} }}`,
783
+ " GH_TOKEN: ${{ github.token }}",
784
+ " run: |",
785
+ // cursor-agent stops at "Workspace Trust Required" without --trust (2026.10.22 on
786
+ // Actions); the folder is this run's own checkout, and forks never get the secret.
787
+ ` agent --trust -p "$(cat <<'${PROMPT_HEREDOC_DELIMITER}'`,
788
+ indentBlock(composePrompt2(input), 4),
789
+ ` ${PROMPT_HEREDOC_DELIMITER}`,
790
+ ' )"'
791
+ ].join("\n")
792
+ ]
793
+ });
599
794
  }
600
795
  var cursorProvider = {
601
796
  id: "cursor",
@@ -633,41 +828,33 @@ function composePrompt3(input) {
633
828
  sections.push(renderFindingsBlockInstruction(input.locale));
634
829
  return sections.join("\n\n---\n\n");
635
830
  }
831
+ var GEMINI_CLI_VERSION = "0.65.0";
636
832
  function buildWorkflowYaml3(input) {
637
- const steps = [
638
- renderSecretGateStep(SECRET_NAME3),
639
- renderRoundGateStep(input.maxRounds),
640
- renderCheckoutStep("steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'"),
641
- [
642
- "- name: Run Gemini review",
643
- " if: steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'",
644
- ` uses: ${pinnedUses("google-github-actions/run-gemini-cli")}`,
645
- " env:",
646
- " GITHUB_TOKEN: ${{ github.token }}",
647
- " with:",
648
- ` gemini_api_key: \${{ secrets.${SECRET_NAME3} }}`,
649
- " prompt: |",
650
- indentBlock(composePrompt3(input), 6)
651
- ].join("\n"),
652
- renderWorkspaceSetupSteps(input.packageManager),
653
- renderVerdictGateStep(input.maxRounds, input.locale)
654
- ];
655
- return [
656
- "name: Gemini Code Review",
657
- "",
658
- renderTriggerBlock(),
659
- "",
660
- renderConcurrencyBlock(),
661
- "",
662
- renderPermissionsBlock(),
663
- "",
664
- "jobs:",
665
- " review:",
666
- " runs-on: ubuntu-latest",
667
- " steps:",
668
- indentBlock(steps.join("\n\n"), 6),
669
- ""
670
- ].join("\n");
833
+ return renderReviewWorkflow(input, {
834
+ name: "Gemini Code Review",
835
+ secretName: SECRET_NAME3,
836
+ // An API key: run-gemini-cli uses OIDC only for Google Cloud workload identity federation.
837
+ idToken: false,
838
+ reviewSteps: [
839
+ renderCheckoutStep(REVIEW_GATE_CONDITION),
840
+ [
841
+ "- name: Run Gemini review",
842
+ ` if: ${REVIEW_GATE_CONDITION}`,
843
+ ` uses: ${pinnedUses("google-github-actions/run-gemini-cli")}`,
844
+ " env:",
845
+ " GITHUB_TOKEN: ${{ github.token }}",
846
+ // Gemini CLI 0.65 refuses to run in a folder it hasn't been told to trust (measured
847
+ // 2026-10-25 on Actions). The folder is this run's own checkout; pull requests from forks
848
+ // never get here, because they get no secret.
849
+ " GEMINI_CLI_TRUST_WORKSPACE: 'true'",
850
+ " with:",
851
+ ` gemini_api_key: \${{ secrets.${SECRET_NAME3} }}`,
852
+ ` gemini_cli_version: '${GEMINI_CLI_VERSION}'`,
853
+ " prompt: |",
854
+ indentBlock(composePrompt3(input), 6)
855
+ ].join("\n")
856
+ ]
857
+ });
671
858
  }
672
859
  var geminiProvider = {
673
860
  id: "gemini",
@@ -706,64 +893,49 @@ function composePrompt4(input) {
706
893
  return sections.join("\n\n---\n\n");
707
894
  }
708
895
  function buildWorkflowYaml4(input) {
709
- const gateCondition = "steps.secret-gate.outputs.enabled == 'true' && steps.round-gate.outputs.allowed == 'true'";
710
- const steps = [
711
- renderSecretGateStep(SECRET_NAME4),
712
- renderRoundGateStep(input.maxRounds),
713
- renderCheckoutStep(gateCondition, ["ref: refs/pull/${{ github.event.pull_request.number }}/merge"]),
714
- [
715
- "- name: Fetch base and head refs for the PR",
716
- ` if: ${gateCondition}`,
717
- " env:",
718
- " PR_BASE_REF: ${{ github.event.pull_request.base.ref }}",
719
- " PR_NUMBER: ${{ github.event.pull_request.number }}",
720
- " run: |",
721
- " # Explicit destinations: the sandboxed review step has no network, so both sides",
722
- " # of the diff must survive as stable local refs (FETCH_HEAD alone is ambiguous).",
723
- " git fetch --no-tags origin \\",
724
- ' "+refs/heads/$PR_BASE_REF:refs/remotes/origin/pr-base" \\',
725
- ' "+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head"'
726
- ].join("\n"),
727
- [
728
- "- name: Run Codex review",
729
- " id: codex-review",
730
- ` if: ${gateCondition}`,
731
- ` uses: ${pinnedUses("openai/codex-action")}`,
732
- " with:",
733
- ` openai-api-key: \${{ secrets.${SECRET_NAME4} }}`,
734
- ' permission-profile: ":read-only"',
735
- " prompt: |",
736
- indentBlock(composePrompt4(input), 6)
737
- ].join("\n"),
738
- [
739
- "- name: Post review comment",
740
- ` if: ${gateCondition}`,
741
- " env:",
742
- " GH_TOKEN: ${{ github.token }}",
743
- " PR_NUMBER: ${{ github.event.pull_request.number }}",
744
- " REVIEW_BODY: ${{ steps.codex-review.outputs.final-message }}",
745
- " run: |",
746
- ' printf %s "$REVIEW_BODY" | gh pr comment "$PR_NUMBER" --body-file -'
747
- ].join("\n"),
748
- renderWorkspaceSetupSteps(input.packageManager),
749
- renderVerdictGateStep(input.maxRounds, input.locale)
750
- ];
751
- return [
752
- "name: GPT Code Review",
753
- "",
754
- renderTriggerBlock(),
755
- "",
756
- renderConcurrencyBlock(),
757
- "",
758
- renderPermissionsBlock(),
759
- "",
760
- "jobs:",
761
- " review:",
762
- " runs-on: ubuntu-latest",
763
- " steps:",
764
- indentBlock(steps.join("\n\n"), 6),
765
- ""
766
- ].join("\n");
896
+ return renderReviewWorkflow(input, {
897
+ name: "GPT Code Review",
898
+ secretName: SECRET_NAME4,
899
+ // An API key: codex-action needs no OIDC token.
900
+ idToken: false,
901
+ reviewSteps: [
902
+ renderCheckoutStep(REVIEW_GATE_CONDITION, ["ref: refs/pull/${{ github.event.pull_request.number }}/merge"]),
903
+ [
904
+ "- name: Fetch base and head refs for the PR",
905
+ ` if: ${REVIEW_GATE_CONDITION}`,
906
+ " env:",
907
+ " PR_BASE_REF: ${{ github.event.pull_request.base.ref }}",
908
+ " PR_NUMBER: ${{ github.event.pull_request.number }}",
909
+ " run: |",
910
+ " # Explicit destinations: the sandboxed review step has no network, so both sides",
911
+ " # of the diff must survive as stable local refs (FETCH_HEAD alone is ambiguous).",
912
+ " git fetch --no-tags origin \\",
913
+ ' "+refs/heads/$PR_BASE_REF:refs/remotes/origin/pr-base" \\',
914
+ ' "+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head"'
915
+ ].join("\n"),
916
+ [
917
+ "- name: Run Codex review",
918
+ " id: codex-review",
919
+ ` if: ${REVIEW_GATE_CONDITION}`,
920
+ ` uses: ${pinnedUses("openai/codex-action")}`,
921
+ " with:",
922
+ ` openai-api-key: \${{ secrets.${SECRET_NAME4} }}`,
923
+ ' permission-profile: ":read-only"',
924
+ " prompt: |",
925
+ indentBlock(composePrompt4(input), 6)
926
+ ].join("\n"),
927
+ [
928
+ "- name: Post review comment",
929
+ ` if: ${REVIEW_GATE_CONDITION}`,
930
+ " env:",
931
+ " GH_TOKEN: ${{ github.token }}",
932
+ " PR_NUMBER: ${{ github.event.pull_request.number }}",
933
+ " REVIEW_BODY: ${{ steps.codex-review.outputs.final-message }}",
934
+ " run: |",
935
+ ' printf %s "$REVIEW_BODY" | gh pr comment "$PR_NUMBER" --body-file -'
936
+ ].join("\n")
937
+ ]
938
+ });
767
939
  }
768
940
  var gptProvider = {
769
941
  id: "gpt",
@@ -850,7 +1022,8 @@ var REVIEW_CHECK_IDS = [
850
1022
  "review-alt-file-coexistence",
851
1023
  "review-config-consistency",
852
1024
  "review-workflow-providers",
853
- "review-workflow-pnpm"
1025
+ "review-workflow-pnpm",
1026
+ "review-workflow-fail-open"
854
1027
  ];
855
1028
  var WORKFLOWS_DIR = ".github/workflows";
856
1029
  function archweaveWorkflows(root) {
@@ -907,6 +1080,25 @@ function buildReviewPnpmCheck(root, workflows) {
907
1080
  suggestedAction: "Regenerate the workflow: nx g @clearlydev/nx-archweave:add-review --onConflict=overwrite."
908
1081
  });
909
1082
  }
1083
+ var FAIL_CLOSED_VERDICT_MODE = "ARCHWEAVE_VERDICT_OUTPUT: 'github-output'";
1084
+ function buildReviewFailOpenCheck(root, workflows) {
1085
+ const id = "review-workflow-fail-open";
1086
+ const label = "Review check fails closed";
1087
+ if (workflows.length === 0)
1088
+ return createDoctorCheck({ id, label, status: "not-applicable", message: "no nx-archweave review workflow", evidence: [] });
1089
+ const failOpen = workflows.filter((path) => !readFileSync2(join2(root, path), "utf-8").includes(FAIL_CLOSED_VERDICT_MODE));
1090
+ if (failOpen.length === 0) {
1091
+ return createDoctorCheck({ id, label, status: "pass", message: "a review run that leaves no readable summary fails the review check", evidence: workflows });
1092
+ }
1093
+ return createDoctorCheck({
1094
+ id,
1095
+ label,
1096
+ status: "error",
1097
+ message: "fail-open workflow: generated before nx-archweave 0.2.2, it passes the review check when the AI review posts no summary or one that cannot be read, so an unreviewed pull request can merge",
1098
+ evidence: failOpen,
1099
+ suggestedAction: "Re-run add-review: nx g @clearlydev/nx-archweave:add-review --onConflict=overwrite. The report shows the difference before anything is replaced."
1100
+ });
1101
+ }
910
1102
  function buildReviewChecks(root) {
911
1103
  const config = readToolkitConfigState(root);
912
1104
  const mainWorkflowPath = REVIEW_PROVIDERS[resolveProviderId(config)].workflowFileName;
@@ -920,7 +1112,8 @@ function buildReviewChecks(root) {
920
1112
  buildReviewAltFileCoexistenceCheck(mainWorkflowPath, altWorkflowPath, mainExists, altExists),
921
1113
  buildReviewConfigConsistencyCheck(root, mainWorkflowPath, mainExists, config),
922
1114
  buildReviewProvidersCheck(workflows),
923
- buildReviewPnpmCheck(root, workflows)
1115
+ buildReviewPnpmCheck(root, workflows),
1116
+ buildReviewFailOpenCheck(root, workflows)
924
1117
  ];
925
1118
  }
926
1119
  function buildReviewWorkflowPresentCheck(mainWorkflowPath, mainExists, mainIsOurs, config) {
@@ -1038,13 +1231,14 @@ function buildReviewConfigConsistencyCheck(root, mainWorkflowPath, mainExists, c
1038
1231
  evidence: [mainWorkflowPath, CONFIG_PATH]
1039
1232
  });
1040
1233
  }
1234
+ const earlierVersion = !/^ {2}review-comment:$/m.test(workflowContent);
1041
1235
  return createDoctorCheck({
1042
1236
  id: "review-config-consistency",
1043
1237
  label: "Review config/workflow consistency",
1044
1238
  status: "warning",
1045
- message: `${mainWorkflowPath} differs from what ${CONFIG_PATH}'s review options would generate`,
1239
+ message: earlierVersion ? `${mainWorkflowPath} was generated by an earlier nx-archweave version: it installs dependencies in the job that holds the provider secret` : `${mainWorkflowPath} differs from what ${CONFIG_PATH}'s review options would generate`,
1046
1240
  evidence: [mainWorkflowPath, CONFIG_PATH],
1047
- suggestedAction: `Re-run the add-review generator with --onConflict=overwrite to re-sync ${mainWorkflowPath} with ${CONFIG_PATH}, or update ${CONFIG_PATH} to match the installed file.`
1241
+ suggestedAction: earlierVersion ? `Re-run add-review --onConflict=overwrite to regenerate ${mainWorkflowPath}: the verdict then runs in a job without the secret, an OIDC token or install scripts. Edits you made to the file are replaced; the report shows the difference first.` : `Re-run the add-review generator with --onConflict=overwrite to re-sync ${mainWorkflowPath} with ${CONFIG_PATH}, or update ${CONFIG_PATH} to match the installed file.`
1048
1242
  });
1049
1243
  }
1050
1244
 
@@ -1158,15 +1352,22 @@ var BACKEND_CHECK_IDS = [
1158
1352
  "backend-config-consistency",
1159
1353
  "shared-types-lib",
1160
1354
  "boundary-constraints-applied",
1161
- "boundary-tags-exist"
1355
+ "boundary-tags-exist",
1356
+ "boundary-untagged-projects"
1162
1357
  ];
1163
1358
  var CHECK_LABELS = {
1164
1359
  "backend-framework": "Backend framework (root package.json only)",
1165
1360
  "backend-config-consistency": "Backend config consistency",
1166
1361
  "shared-types-lib": "shared-types library",
1167
1362
  "boundary-constraints-applied": "Module-boundary constraints",
1168
- "boundary-tags-exist": "Boundary tags exist"
1363
+ "boundary-tags-exist": "Boundary tags exist",
1364
+ "boundary-untagged-projects": "Projects without a boundary tag"
1169
1365
  };
1366
+ var UNTAGGED_LISTED = 10;
1367
+ function isEndToEndRoot(projectRoot) {
1368
+ const base = projectRoot.split("/").pop() ?? "";
1369
+ return base === "e2e" || base.endsWith("-e2e");
1370
+ }
1170
1371
  function collectScopeTags(root, projects) {
1171
1372
  if (projects !== void 0)
1172
1373
  return new Set(projects.flatMap((project) => project.tags));
@@ -1323,13 +1524,15 @@ function buildBackendChecks(root, projects) {
1323
1524
  suggestedAction: classification === "enforced" ? void 0 : "Run add-backend with --applyEslint, or merge its snippet yourself."
1324
1525
  }));
1325
1526
  if (classification !== "enforced") {
1326
- checks.push(createDoctorCheck({
1327
- id: "boundary-tags-exist",
1328
- label: CHECK_LABELS["boundary-tags-exist"],
1329
- status: "not-applicable",
1330
- message: "Skipped \u2014 boundary constraints are not enforced (see boundary-constraints-applied)",
1331
- evidence: []
1332
- }));
1527
+ for (const id of ["boundary-tags-exist", "boundary-untagged-projects"]) {
1528
+ checks.push(createDoctorCheck({
1529
+ id,
1530
+ label: CHECK_LABELS[id],
1531
+ status: "not-applicable",
1532
+ message: "Skipped \u2014 boundary constraints are not enforced (see boundary-constraints-applied)",
1533
+ evidence: []
1534
+ }));
1535
+ }
1333
1536
  } else {
1334
1537
  const tags = collectScopeTags(root, projects);
1335
1538
  const required = readsBackendOnly(root) ? [boundaryTags.backend] : [boundaryTags.frontend, boundaryTags.backend];
@@ -1342,9 +1545,36 @@ function buildBackendChecks(root, projects) {
1342
1545
  evidence: missing.length === 0 ? [] : missing,
1343
1546
  suggestedAction: missing.length === 0 ? void 0 : "Add the missing tag(s) to the app project.json tags or package.json nx.tags (add-backend does this for the two apps)."
1344
1547
  }));
1548
+ checks.push(buildUntaggedProjectsCheck(projects, [boundaryTags.frontend, boundaryTags.backend, boundaryTags.shared]));
1345
1549
  }
1346
1550
  return checks;
1347
1551
  }
1552
+ function buildUntaggedProjectsCheck(projects, roleTags) {
1553
+ const id = "boundary-untagged-projects";
1554
+ if (projects === void 0) {
1555
+ return createDoctorCheck({
1556
+ id,
1557
+ label: CHECK_LABELS[id],
1558
+ status: "not-applicable",
1559
+ message: "Skipped \u2014 needs the workspace's Nx project list (run doctor through nx g)",
1560
+ evidence: []
1561
+ });
1562
+ }
1563
+ const untagged = projects.filter((project) => project.root !== "" && project.root !== "." && !isEndToEndRoot(project.root)).filter((project) => !project.tags.some((tag) => roleTags.includes(tag))).map((project) => project.name);
1564
+ if (untagged.length === 0) {
1565
+ return createDoctorCheck({ id, label: CHECK_LABELS[id], status: "pass", message: `every project carries one of ${roleTags.join(", ")}`, evidence: [] });
1566
+ }
1567
+ const listed = untagged.slice(0, UNTAGGED_LISTED).join(", ");
1568
+ const more = untagged.length > UNTAGGED_LISTED ? ` and ${untagged.length - UNTAGGED_LISTED} more` : "";
1569
+ return createDoctorCheck({
1570
+ id,
1571
+ label: CHECK_LABELS[id],
1572
+ status: "warning",
1573
+ message: `${untagged.length} project(s) carry none of ${roleTags.join(", ")}, so the frontend/backend deny rules don't cover them: ${listed}${more}`,
1574
+ evidence: untagged,
1575
+ suggestedAction: `Give each one the tag of its side (${roleTags.join(", ")}) in package.json nx.tags or project.json tags. When you generate a library, pass it right away: nx g @nx/js:lib libs/<name> --tags=${roleTags[2]}.`
1576
+ });
1577
+ }
1348
1578
 
1349
1579
  // ../template-engine/dist/doctor/fsd-like-check.js
1350
1580
  import { existsSync as existsSync4, readdirSync as readdirSync3 } from "node:fs";
@@ -1595,7 +1825,7 @@ var NOT_APPLICABLE_IDS = [
1595
1825
  ...REVIEW_CHECK_IDS,
1596
1826
  ...BACKEND_CHECK_IDS
1597
1827
  ];
1598
- function runDoctorChecks(root, projects) {
1828
+ function runDoctorChecks(root, projects, nxSupport) {
1599
1829
  const env = detectEnvironment(root);
1600
1830
  if (!env.hasNxWorkspace) {
1601
1831
  const checks2 = [
@@ -1625,7 +1855,7 @@ function runDoctorChecks(root, projects) {
1625
1855
  message: "nx.json found",
1626
1856
  evidence: ["nx.json"]
1627
1857
  }),
1628
- buildNxVersionCheck(env),
1858
+ buildNxVersionCheck(env, nxSupport),
1629
1859
  buildFsdLikeCheck(root, projects),
1630
1860
  buildFsdEnforcementCheck(root, projects),
1631
1861
  buildFsdRuleAnchoredCheck(root, projects),
@@ -1654,12 +1884,25 @@ function runDoctorChecks(root, projects) {
1654
1884
  const overallStatus = computeOverallStatus(checks);
1655
1885
  return { checks, overallStatus };
1656
1886
  }
1657
- var VALIDATED_NX_MAJOR = 23;
1887
+ var VALIDATED_NX_MAJORS = [23, 24];
1888
+ var VALIDATED_RANGE_TEXT = `${VALIDATED_NX_MAJORS[0]}.x\u2013${VALIDATED_NX_MAJORS[VALIDATED_NX_MAJORS.length - 1]}.x`;
1658
1889
  function extractMajor(version) {
1659
1890
  const match = /(\d+)/.exec(version);
1660
1891
  return match ? Number(match[1]) : null;
1661
1892
  }
1662
- function buildNxVersionCheck(env) {
1893
+ function buildNxVersionCheck(env, nxSupport) {
1894
+ if (nxSupport?.inRange === false && nxSupport.installedVersion !== void 0) {
1895
+ const major2 = extractMajor(nxSupport.installedVersion);
1896
+ const supported = `${nxSupport.supportedMajors.min}.x\u2013${nxSupport.supportedMajors.max}.x`;
1897
+ return createDoctorCheck({
1898
+ id: "nx-version",
1899
+ label: "Nx version",
1900
+ status: "error",
1901
+ message: `Nx ${nxSupport.installedVersion} is installed, outside the majors the plugin's @nx/devkit ${nxSupport.devkitVersion} supports (nx ${supported}) \u2014 the generators may fail to load or misbehave`,
1902
+ evidence: [`nx@${nxSupport.installedVersion}`, `@nx/devkit@${nxSupport.devkitVersion}`],
1903
+ suggestedAction: `Install the @clearlydev/nx-archweave release built for Nx ${major2 ?? "this"}${major2 === null ? "" : ".x"}, or keep nx at ${supported}.`
1904
+ });
1905
+ }
1663
1906
  if (env.rootPackageJsonError) {
1664
1907
  return createDoctorCheck({
1665
1908
  id: "nx-version",
@@ -1689,30 +1932,30 @@ function buildNxVersionCheck(env) {
1689
1932
  evidence: [`nx@${env.nxVersion}`]
1690
1933
  });
1691
1934
  }
1692
- if (major === VALIDATED_NX_MAJOR) {
1935
+ if (VALIDATED_NX_MAJORS.includes(major)) {
1693
1936
  return createDoctorCheck({
1694
1937
  id: "nx-version",
1695
1938
  label: "Nx version",
1696
1939
  status: "pass",
1697
- message: `Nx ${env.nxVersion} \u2014 within the toolkit-validated range (${VALIDATED_NX_MAJOR}.x)`,
1940
+ message: `Nx ${env.nxVersion} \u2014 within the toolkit-validated range (${VALIDATED_RANGE_TEXT})`,
1698
1941
  evidence: [`nx@${env.nxVersion}`]
1699
1942
  });
1700
1943
  }
1701
- if (major < VALIDATED_NX_MAJOR) {
1944
+ if (major < VALIDATED_NX_MAJORS[0]) {
1702
1945
  return createDoctorCheck({
1703
1946
  id: "nx-version",
1704
1947
  label: "Nx version",
1705
1948
  status: "warning",
1706
- message: `Nx ${env.nxVersion} is below the toolkit-validated range (${VALIDATED_NX_MAJOR}.x) \u2014 generator behavior is not guaranteed`,
1949
+ message: `Nx ${env.nxVersion} is below the toolkit-validated range (${VALIDATED_RANGE_TEXT}) \u2014 generator behavior is not guaranteed`,
1707
1950
  evidence: [`nx@${env.nxVersion}`],
1708
- suggestedAction: `Upgrade the workspace to Nx ${VALIDATED_NX_MAJOR}.x before running the generators.`
1951
+ suggestedAction: `Upgrade the workspace to Nx ${VALIDATED_NX_MAJORS[VALIDATED_NX_MAJORS.length - 1]}.x before running the generators.`
1709
1952
  });
1710
1953
  }
1711
1954
  return createDoctorCheck({
1712
1955
  id: "nx-version",
1713
1956
  label: "Nx version",
1714
1957
  status: "warning",
1715
- message: `Nx ${env.nxVersion} is above the toolkit-validated range (${VALIDATED_NX_MAJOR}.x) \u2014 may work, unvalidated`,
1958
+ message: `Nx ${env.nxVersion} is above the toolkit-validated range (${VALIDATED_RANGE_TEXT}) \u2014 may work, unvalidated`,
1716
1959
  evidence: [`nx@${env.nxVersion}`],
1717
1960
  suggestedAction: "Proceed with care and report issues; validation for newer majors lands with toolkit updates."
1718
1961
  });
@@ -2644,7 +2887,7 @@ function diskRunNx(root) {
2644
2887
  var DOCTOR_TARGET_ENVIRONMENT = "claude-code";
2645
2888
  async function buildDoctorReportWithIntegrations(root, options = {}, deps = {}) {
2646
2889
  const integrationsRoot = deps.integrationsRoot ?? createIntegrationsCompositionRoot();
2647
- const baseReport = runDoctorChecks(root, options.projects);
2890
+ const baseReport = runDoctorChecks(root, options.projects, options.nxSupport);
2648
2891
  const declaredIds = readEnabledIntegrationIds(root);
2649
2892
  const runNx = diskRunNx(root);
2650
2893
  const driftResults = await detectIntegrationsDrift({
@@ -3093,11 +3336,49 @@ function buildPolicySuggestion(tree, sourceRoot, current) {
3093
3336
  });
3094
3337
  }
3095
3338
 
3339
+ // src/generators/doctor/nx-support.ts
3340
+ import { readFileSync as readFileSync9 } from "node:fs";
3341
+ import { createRequire } from "node:module";
3342
+ import { join as join12 } from "node:path";
3343
+ var MAJORS_EITHER_SIDE = 1;
3344
+ function majorOf(version) {
3345
+ const match = /^(\d+)\./.exec(version.trim());
3346
+ return match ? Number(match[1]) : void 0;
3347
+ }
3348
+ function readOwnDevkitVersion() {
3349
+ try {
3350
+ const path = createRequire(import.meta.url).resolve("@nx/devkit/package.json");
3351
+ const version = JSON.parse(readFileSync9(path, "utf-8")).version;
3352
+ return typeof version === "string" ? version : void 0;
3353
+ } catch {
3354
+ return void 0;
3355
+ }
3356
+ }
3357
+ function readInstalledNx(root) {
3358
+ try {
3359
+ const version = JSON.parse(readFileSync9(join12(root, "node_modules", "nx", "package.json"), "utf-8")).version;
3360
+ return typeof version === "string" ? version : void 0;
3361
+ } catch {
3362
+ return void 0;
3363
+ }
3364
+ }
3365
+ function readNxSupport(root, deps = {}) {
3366
+ const devkitVersion = (deps.devkitVersion ?? readOwnDevkitVersion)();
3367
+ const devkitMajor = devkitVersion === void 0 ? void 0 : majorOf(devkitVersion);
3368
+ if (devkitVersion === void 0 || devkitMajor === void 0) return void 0;
3369
+ const supportedMajors = { min: devkitMajor - MAJORS_EITHER_SIDE, max: devkitMajor + MAJORS_EITHER_SIDE };
3370
+ const installedVersion = (deps.installedNx ?? readInstalledNx)(root);
3371
+ const installedMajor = installedVersion === void 0 ? void 0 : majorOf(installedVersion);
3372
+ const inRange = installedMajor === void 0 ? void 0 : installedMajor >= supportedMajors.min && installedMajor <= supportedMajors.max;
3373
+ return { devkitVersion, supportedMajors, installedVersion, inRange };
3374
+ }
3375
+
3096
3376
  // src/generators/doctor/generator.ts
3097
3377
  async function doctorGenerator(tree, options) {
3098
3378
  const base = await buildDoctorReportWithIntegrations(tree.root, {
3099
3379
  liveIntegrations: options.liveIntegrations ?? false,
3100
- projects: doctorProjects(tree)
3380
+ projects: doctorProjects(tree),
3381
+ nxSupport: readNxSupport(tree.root)
3101
3382
  });
3102
3383
  const checks = base.overallStatus === "unsupported" ? base.checks : [...base.checks, ...buildWorkspaceChecks(tree)];
3103
3384
  const report = { checks, overallStatus: base.overallStatus === "unsupported" ? base.overallStatus : computeOverallStatus(checks) };