discoclaw 0.1.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/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolveTextType, isTextType, classifyAttachments, downloadTextAttachments } from './file-download.js';
|
|
3
|
+
function makeAtt(name, contentType, size = 100) {
|
|
4
|
+
return { url: `https://cdn.discordapp.com/attachments/1/2/${name}`, name, contentType, size };
|
|
5
|
+
}
|
|
6
|
+
describe('resolveTextType', () => {
|
|
7
|
+
it('returns MIME for text/plain', () => {
|
|
8
|
+
expect(resolveTextType(makeAtt('a.txt', 'text/plain'))).toBe('text/plain');
|
|
9
|
+
});
|
|
10
|
+
it('returns MIME for application/json', () => {
|
|
11
|
+
expect(resolveTextType(makeAtt('a.json', 'application/json'))).toBe('application/json');
|
|
12
|
+
});
|
|
13
|
+
it('returns MIME for text/csv', () => {
|
|
14
|
+
expect(resolveTextType(makeAtt('a.csv', 'text/csv'))).toBe('text/csv');
|
|
15
|
+
});
|
|
16
|
+
it('strips charset from contentType', () => {
|
|
17
|
+
expect(resolveTextType(makeAtt('a.txt', 'text/plain; charset=utf-8'))).toBe('text/plain');
|
|
18
|
+
});
|
|
19
|
+
it('falls back to extension for .json', () => {
|
|
20
|
+
expect(resolveTextType(makeAtt('config.json', null))).toBe('application/json');
|
|
21
|
+
});
|
|
22
|
+
it('falls back to extension for .md', () => {
|
|
23
|
+
expect(resolveTextType(makeAtt('README.md', null))).toBe('text/markdown');
|
|
24
|
+
});
|
|
25
|
+
it('falls back to extension for .py', () => {
|
|
26
|
+
expect(resolveTextType(makeAtt('script.py', null))).toBe('text/x-script');
|
|
27
|
+
});
|
|
28
|
+
it('falls back to extension for .yml', () => {
|
|
29
|
+
expect(resolveTextType(makeAtt('config.yml', null))).toBe('text/yaml');
|
|
30
|
+
});
|
|
31
|
+
it('falls back to extension for .yaml', () => {
|
|
32
|
+
expect(resolveTextType(makeAtt('config.yaml', null))).toBe('text/yaml');
|
|
33
|
+
});
|
|
34
|
+
it('falls back to extension for .sh', () => {
|
|
35
|
+
expect(resolveTextType(makeAtt('build.sh', null))).toBe('text/x-script');
|
|
36
|
+
});
|
|
37
|
+
it('returns null for image types', () => {
|
|
38
|
+
expect(resolveTextType(makeAtt('photo.png', 'image/png'))).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
it('returns null for PDF', () => {
|
|
41
|
+
expect(resolveTextType(makeAtt('doc.pdf', 'application/pdf'))).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
it('returns null for unknown extension and no contentType', () => {
|
|
44
|
+
expect(resolveTextType(makeAtt('data.xyz', null))).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
// --- New extension tests ---
|
|
47
|
+
it('falls back to extension for .go', () => {
|
|
48
|
+
expect(resolveTextType(makeAtt('main.go', null))).toBe('text/x-script');
|
|
49
|
+
});
|
|
50
|
+
it('falls back to extension for .rs', () => {
|
|
51
|
+
expect(resolveTextType(makeAtt('lib.rs', null))).toBe('text/x-script');
|
|
52
|
+
});
|
|
53
|
+
it('falls back to extension for .css', () => {
|
|
54
|
+
expect(resolveTextType(makeAtt('styles.css', null))).toBe('text/css');
|
|
55
|
+
});
|
|
56
|
+
it('falls back to extension for .jsx', () => {
|
|
57
|
+
expect(resolveTextType(makeAtt('App.jsx', null))).toBe('application/javascript');
|
|
58
|
+
});
|
|
59
|
+
it('falls back to extension for .tsx', () => {
|
|
60
|
+
expect(resolveTextType(makeAtt('App.tsx', null))).toBe('application/typescript');
|
|
61
|
+
});
|
|
62
|
+
it('falls back to extension for .mjs', () => {
|
|
63
|
+
expect(resolveTextType(makeAtt('index.mjs', null))).toBe('application/javascript');
|
|
64
|
+
});
|
|
65
|
+
it('falls back to extension for .mts', () => {
|
|
66
|
+
expect(resolveTextType(makeAtt('index.mts', null))).toBe('application/typescript');
|
|
67
|
+
});
|
|
68
|
+
it('falls back to extension for .toml', () => {
|
|
69
|
+
expect(resolveTextType(makeAtt('config.toml', null))).toBe('application/toml');
|
|
70
|
+
});
|
|
71
|
+
it('falls back to extension for .env', () => {
|
|
72
|
+
expect(resolveTextType(makeAtt('app.env', null))).toBe('text/plain');
|
|
73
|
+
});
|
|
74
|
+
it('falls back to extension for .sql', () => {
|
|
75
|
+
expect(resolveTextType(makeAtt('schema.sql', null))).toBe('application/sql');
|
|
76
|
+
});
|
|
77
|
+
it('falls back to extension for .tf', () => {
|
|
78
|
+
expect(resolveTextType(makeAtt('main.tf', null))).toBe('text/plain');
|
|
79
|
+
});
|
|
80
|
+
it('falls back to extension for .proto', () => {
|
|
81
|
+
expect(resolveTextType(makeAtt('service.proto', null))).toBe('text/plain');
|
|
82
|
+
});
|
|
83
|
+
it('falls back to extension for .jsonc', () => {
|
|
84
|
+
expect(resolveTextType(makeAtt('tsconfig.jsonc', null))).toBe('application/json');
|
|
85
|
+
});
|
|
86
|
+
it('falls back to extension for .dockerfile', () => {
|
|
87
|
+
expect(resolveTextType(makeAtt('app.dockerfile', null))).toBe('text/plain');
|
|
88
|
+
});
|
|
89
|
+
it('falls back to extension for .log', () => {
|
|
90
|
+
expect(resolveTextType(makeAtt('error.log', null))).toBe('text/plain');
|
|
91
|
+
});
|
|
92
|
+
it('falls back to extension for .svg', () => {
|
|
93
|
+
expect(resolveTextType(makeAtt('icon.svg', null))).toBe('text/xml');
|
|
94
|
+
});
|
|
95
|
+
it('falls back to extension for .astro', () => {
|
|
96
|
+
expect(resolveTextType(makeAtt('Page.astro', null))).toBe('text/html');
|
|
97
|
+
});
|
|
98
|
+
it('falls back to extension for .bat', () => {
|
|
99
|
+
expect(resolveTextType(makeAtt('build.bat', null))).toBe('text/x-script');
|
|
100
|
+
});
|
|
101
|
+
it('falls back to extension for .gd (GDScript)', () => {
|
|
102
|
+
expect(resolveTextType(makeAtt('player.gd', null))).toBe('text/x-script');
|
|
103
|
+
});
|
|
104
|
+
// --- Bare dotfile tests (dotIdx === 0 path) ---
|
|
105
|
+
it('resolves bare .env dotfile', () => {
|
|
106
|
+
expect(resolveTextType(makeAtt('.env', null))).toBe('text/plain');
|
|
107
|
+
});
|
|
108
|
+
it('resolves bare .gitignore dotfile', () => {
|
|
109
|
+
expect(resolveTextType(makeAtt('.gitignore', null))).toBe('text/plain');
|
|
110
|
+
});
|
|
111
|
+
it('resolves bare .prettierrc dotfile', () => {
|
|
112
|
+
expect(resolveTextType(makeAtt('.prettierrc', null))).toBe('text/plain');
|
|
113
|
+
});
|
|
114
|
+
// --- Negative cases ---
|
|
115
|
+
it('returns null for .exe (binary)', () => {
|
|
116
|
+
expect(resolveTextType(makeAtt('app.exe', null))).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
it('returns null for .dll (binary)', () => {
|
|
119
|
+
expect(resolveTextType(makeAtt('lib.dll', null))).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('isTextType', () => {
|
|
123
|
+
it('returns true for text/* types', () => {
|
|
124
|
+
expect(isTextType('text/plain')).toBe(true);
|
|
125
|
+
expect(isTextType('text/x-script')).toBe(true);
|
|
126
|
+
expect(isTextType('text/csv')).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
it('returns true for application text types', () => {
|
|
129
|
+
expect(isTextType('application/json')).toBe(true);
|
|
130
|
+
expect(isTextType('application/javascript')).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
it('returns true for application/toml', () => {
|
|
133
|
+
expect(isTextType('application/toml')).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
it('returns true for application/sql', () => {
|
|
136
|
+
expect(isTextType('application/sql')).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
it('returns true for application/graphql', () => {
|
|
139
|
+
expect(isTextType('application/graphql')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
it('returns true for text/css', () => {
|
|
142
|
+
expect(isTextType('text/css')).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
it('returns false for non-text types', () => {
|
|
145
|
+
expect(isTextType('application/pdf')).toBe(false);
|
|
146
|
+
expect(isTextType('image/png')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('classifyAttachments', () => {
|
|
150
|
+
it('separates text from unsupported', () => {
|
|
151
|
+
const atts = [
|
|
152
|
+
makeAtt('code.js', 'application/javascript'),
|
|
153
|
+
makeAtt('doc.pdf', 'application/pdf'),
|
|
154
|
+
makeAtt('notes.txt', 'text/plain'),
|
|
155
|
+
];
|
|
156
|
+
const { text, unsupported } = classifyAttachments(atts);
|
|
157
|
+
expect(text).toHaveLength(2);
|
|
158
|
+
expect(unsupported).toHaveLength(1);
|
|
159
|
+
expect(unsupported[0].name).toBe('doc.pdf');
|
|
160
|
+
});
|
|
161
|
+
it('handles empty input', () => {
|
|
162
|
+
const { text, unsupported } = classifyAttachments([]);
|
|
163
|
+
expect(text).toHaveLength(0);
|
|
164
|
+
expect(unsupported).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('downloadTextAttachments', () => {
|
|
168
|
+
const originalFetch = globalThis.fetch;
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
globalThis.fetch = vi.fn();
|
|
171
|
+
});
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
globalThis.fetch = originalFetch;
|
|
174
|
+
});
|
|
175
|
+
it('downloads text file content', async () => {
|
|
176
|
+
const content = 'hello world';
|
|
177
|
+
globalThis.fetch.mockResolvedValue({
|
|
178
|
+
ok: true,
|
|
179
|
+
arrayBuffer: () => Promise.resolve(Buffer.from(content)),
|
|
180
|
+
});
|
|
181
|
+
const result = await downloadTextAttachments([
|
|
182
|
+
makeAtt('hello.txt', 'text/plain', 11),
|
|
183
|
+
]);
|
|
184
|
+
expect(result.texts).toHaveLength(1);
|
|
185
|
+
expect(result.texts[0].name).toBe('hello.txt');
|
|
186
|
+
expect(result.texts[0].content).toBe('hello world');
|
|
187
|
+
expect(result.errors).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
it('notes unsupported attachment types', async () => {
|
|
190
|
+
const result = await downloadTextAttachments([
|
|
191
|
+
makeAtt('doc.pdf', 'application/pdf', 100),
|
|
192
|
+
]);
|
|
193
|
+
expect(result.texts).toHaveLength(0);
|
|
194
|
+
expect(result.errors).toHaveLength(1);
|
|
195
|
+
expect(result.errors[0]).toContain('Unsupported attachment');
|
|
196
|
+
expect(result.errors[0]).toContain('doc.pdf');
|
|
197
|
+
expect(result.errors[0]).toContain('application/pdf');
|
|
198
|
+
});
|
|
199
|
+
it('blocks non-Discord CDN URLs (SSRF)', async () => {
|
|
200
|
+
const att = {
|
|
201
|
+
url: 'https://evil.com/secret.txt',
|
|
202
|
+
name: 'secret.txt',
|
|
203
|
+
contentType: 'text/plain',
|
|
204
|
+
size: 10,
|
|
205
|
+
};
|
|
206
|
+
const result = await downloadTextAttachments([att]);
|
|
207
|
+
expect(result.texts).toHaveLength(0);
|
|
208
|
+
expect(result.errors).toHaveLength(1);
|
|
209
|
+
expect(result.errors[0]).toContain('blocked');
|
|
210
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
it('truncates files exceeding per-file size limit', async () => {
|
|
213
|
+
const bigContent = 'A'.repeat(150 * 1024); // 150KB
|
|
214
|
+
globalThis.fetch.mockResolvedValue({
|
|
215
|
+
ok: true,
|
|
216
|
+
arrayBuffer: () => Promise.resolve(Buffer.from(bigContent)),
|
|
217
|
+
});
|
|
218
|
+
const result = await downloadTextAttachments([
|
|
219
|
+
makeAtt('big.txt', 'text/plain', 150 * 1024),
|
|
220
|
+
]);
|
|
221
|
+
expect(result.texts).toHaveLength(1);
|
|
222
|
+
expect(result.texts[0].content).toContain('[truncated at 100KB]');
|
|
223
|
+
expect(result.texts[0].content.length).toBeLessThan(bigContent.length);
|
|
224
|
+
});
|
|
225
|
+
it('skips files when total budget is exceeded', async () => {
|
|
226
|
+
const content = 'A'.repeat(150 * 1024); // 150KB each
|
|
227
|
+
globalThis.fetch.mockResolvedValue({
|
|
228
|
+
ok: true,
|
|
229
|
+
arrayBuffer: () => Promise.resolve(Buffer.from(content)),
|
|
230
|
+
});
|
|
231
|
+
const result = await downloadTextAttachments([
|
|
232
|
+
makeAtt('a.txt', 'text/plain', 150 * 1024),
|
|
233
|
+
makeAtt('b.txt', 'text/plain', 150 * 1024), // would exceed 200KB total
|
|
234
|
+
]);
|
|
235
|
+
expect(result.texts).toHaveLength(1);
|
|
236
|
+
expect(result.errors.some(e => e.includes('total size limit'))).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
it('handles HTTP errors', async () => {
|
|
239
|
+
globalThis.fetch.mockResolvedValue({ ok: false, status: 404 });
|
|
240
|
+
const result = await downloadTextAttachments([
|
|
241
|
+
makeAtt('missing.txt', 'text/plain', 10),
|
|
242
|
+
]);
|
|
243
|
+
expect(result.texts).toHaveLength(0);
|
|
244
|
+
expect(result.errors).toHaveLength(1);
|
|
245
|
+
expect(result.errors[0]).toContain('HTTP 404');
|
|
246
|
+
});
|
|
247
|
+
it('handles download timeout', async () => {
|
|
248
|
+
const timeoutErr = new DOMException('signal timed out', 'TimeoutError');
|
|
249
|
+
globalThis.fetch.mockRejectedValue(timeoutErr);
|
|
250
|
+
const result = await downloadTextAttachments([
|
|
251
|
+
makeAtt('slow.txt', 'text/plain', 10),
|
|
252
|
+
]);
|
|
253
|
+
expect(result.texts).toHaveLength(0);
|
|
254
|
+
expect(result.errors).toHaveLength(1);
|
|
255
|
+
expect(result.errors[0]).toContain('timed out');
|
|
256
|
+
});
|
|
257
|
+
it('rejects non-UTF8 content', async () => {
|
|
258
|
+
// Create a buffer with invalid UTF-8 bytes
|
|
259
|
+
const badBuffer = Buffer.from([0xff, 0xfe, 0x80, 0x81]);
|
|
260
|
+
globalThis.fetch.mockResolvedValue({
|
|
261
|
+
ok: true,
|
|
262
|
+
arrayBuffer: () => Promise.resolve(badBuffer.buffer.slice(badBuffer.byteOffset, badBuffer.byteOffset + badBuffer.byteLength)),
|
|
263
|
+
});
|
|
264
|
+
const result = await downloadTextAttachments([
|
|
265
|
+
makeAtt('binary.txt', 'text/plain', 4),
|
|
266
|
+
]);
|
|
267
|
+
expect(result.texts).toHaveLength(0);
|
|
268
|
+
expect(result.errors).toHaveLength(1);
|
|
269
|
+
expect(result.errors[0]).toContain('not valid UTF-8');
|
|
270
|
+
});
|
|
271
|
+
it('handles empty input', async () => {
|
|
272
|
+
const result = await downloadTextAttachments([]);
|
|
273
|
+
expect(result.texts).toHaveLength(0);
|
|
274
|
+
expect(result.errors).toHaveLength(0);
|
|
275
|
+
});
|
|
276
|
+
it('handles mix of text and unsupported files', async () => {
|
|
277
|
+
const content = 'data';
|
|
278
|
+
globalThis.fetch.mockResolvedValue({
|
|
279
|
+
ok: true,
|
|
280
|
+
arrayBuffer: () => Promise.resolve(Buffer.from(content)),
|
|
281
|
+
});
|
|
282
|
+
const result = await downloadTextAttachments([
|
|
283
|
+
makeAtt('code.js', 'application/javascript', 4),
|
|
284
|
+
makeAtt('archive.zip', 'application/zip', 1000),
|
|
285
|
+
makeAtt('notes.md', null, 4), // extension fallback
|
|
286
|
+
]);
|
|
287
|
+
expect(result.texts).toHaveLength(2); // code.js + notes.md
|
|
288
|
+
expect(result.errors).toHaveLength(1); // archive.zip unsupported
|
|
289
|
+
expect(result.errors[0]).toContain('archive.zip');
|
|
290
|
+
});
|
|
291
|
+
it('uses extension fallback when contentType is missing', async () => {
|
|
292
|
+
const content = '{"key":"value"}';
|
|
293
|
+
globalThis.fetch.mockResolvedValue({
|
|
294
|
+
ok: true,
|
|
295
|
+
arrayBuffer: () => Promise.resolve(Buffer.from(content)),
|
|
296
|
+
});
|
|
297
|
+
const result = await downloadTextAttachments([
|
|
298
|
+
makeAtt('config.json', null, 15),
|
|
299
|
+
]);
|
|
300
|
+
expect(result.texts).toHaveLength(1);
|
|
301
|
+
expect(result.texts[0].content).toBe('{"key":"value"}');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { extractFirstJsonValue } from './json-extract.js';
|
|
2
|
+
const SEVERITY_RANK = {
|
|
3
|
+
none: 0,
|
|
4
|
+
suggestion: 1,
|
|
5
|
+
minor: 2,
|
|
6
|
+
medium: 3,
|
|
7
|
+
blocking: 4,
|
|
8
|
+
};
|
|
9
|
+
function normalizeSeverity(raw) {
|
|
10
|
+
if (!raw)
|
|
11
|
+
return null;
|
|
12
|
+
const normalized = raw.trim().toLowerCase();
|
|
13
|
+
if (normalized === 'high')
|
|
14
|
+
return 'blocking';
|
|
15
|
+
if (normalized === 'low')
|
|
16
|
+
return 'minor';
|
|
17
|
+
if (normalized === 'blocking' ||
|
|
18
|
+
normalized === 'medium' ||
|
|
19
|
+
normalized === 'minor' ||
|
|
20
|
+
normalized === 'suggestion' ||
|
|
21
|
+
normalized === 'none') {
|
|
22
|
+
return normalized;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function maxSeverityFromList(severities) {
|
|
27
|
+
let max = 'none';
|
|
28
|
+
for (const sev of severities) {
|
|
29
|
+
if (SEVERITY_RANK[sev] > SEVERITY_RANK[max]) {
|
|
30
|
+
max = sev;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return max;
|
|
34
|
+
}
|
|
35
|
+
function tryParseJsonVerdict(auditText) {
|
|
36
|
+
const jsonCandidate = extractFirstJsonValue(auditText, { objectOnly: true });
|
|
37
|
+
if (!jsonCandidate)
|
|
38
|
+
return null;
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(jsonCandidate);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
47
|
+
return null;
|
|
48
|
+
const payload = parsed;
|
|
49
|
+
const verdictPayload = payload.verdict && typeof payload.verdict === 'object'
|
|
50
|
+
? payload.verdict
|
|
51
|
+
: payload;
|
|
52
|
+
const topSeverity = normalizeSeverity(verdictPayload.maxSeverity);
|
|
53
|
+
const concernSeverities = [];
|
|
54
|
+
for (const concern of verdictPayload.concerns ?? []) {
|
|
55
|
+
const severity = normalizeSeverity(concern?.severity);
|
|
56
|
+
if (severity)
|
|
57
|
+
concernSeverities.push(severity);
|
|
58
|
+
}
|
|
59
|
+
const hasStructuredVerdict = topSeverity !== null ||
|
|
60
|
+
typeof verdictPayload.shouldLoop === 'boolean' ||
|
|
61
|
+
concernSeverities.length > 0;
|
|
62
|
+
if (!hasStructuredVerdict)
|
|
63
|
+
return null;
|
|
64
|
+
const concernMax = maxSeverityFromList(concernSeverities);
|
|
65
|
+
let maxSeverity = topSeverity ?? concernMax;
|
|
66
|
+
const shouldLoop = typeof verdictPayload.shouldLoop === 'boolean'
|
|
67
|
+
? verdictPayload.shouldLoop
|
|
68
|
+
: maxSeverity === 'blocking';
|
|
69
|
+
if (shouldLoop && maxSeverity === 'none') {
|
|
70
|
+
maxSeverity = 'blocking';
|
|
71
|
+
}
|
|
72
|
+
return { maxSeverity, shouldLoop };
|
|
73
|
+
}
|
|
74
|
+
function parseAuditVerdictLegacy(auditText) {
|
|
75
|
+
const lower = auditText.toLowerCase();
|
|
76
|
+
// --- Severity detection ---
|
|
77
|
+
// Primary: "Severity: blocking" or "Severity: **high**" (structured format we ask for)
|
|
78
|
+
// Secondary: table cells like "| **blocking** |" or "| medium |".
|
|
79
|
+
// Tertiary: parenthesized severity like "Concern 1 (high)" or "(medium)".
|
|
80
|
+
// We intentionally avoid matching free-form bold words in prose to prevent
|
|
81
|
+
// false positives like "the impact is **high**" in a description paragraph.
|
|
82
|
+
const severityLabel = /\bseverity\b[:\s]*\**\s*(blocking|high|medium|minor|low|suggestion)\b/gi;
|
|
83
|
+
const tableCellSeverity = /\|\s*\**\s*(blocking|high|medium|minor|low|suggestion)\s*\**\s*\|/gi;
|
|
84
|
+
const bareSeverity = /\((blocking|high|medium|minor|low|suggestion)\)/gi;
|
|
85
|
+
// Collect all severity mentions from all patterns
|
|
86
|
+
const found = new Set();
|
|
87
|
+
for (const re of [severityLabel, tableCellSeverity, bareSeverity]) {
|
|
88
|
+
let m;
|
|
89
|
+
while ((m = re.exec(auditText)) !== null) {
|
|
90
|
+
found.add(m[1].toLowerCase());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Normalize backward-compat aliases: high -> blocking, low -> minor
|
|
94
|
+
if (found.has('high')) {
|
|
95
|
+
found.delete('high');
|
|
96
|
+
found.add('blocking');
|
|
97
|
+
}
|
|
98
|
+
if (found.has('low')) {
|
|
99
|
+
found.delete('low');
|
|
100
|
+
found.add('minor');
|
|
101
|
+
}
|
|
102
|
+
// Determine max severity from markers (blocking > medium > minor > suggestion)
|
|
103
|
+
const markerSeverity = found.has('blocking')
|
|
104
|
+
? 'blocking'
|
|
105
|
+
: found.has('medium')
|
|
106
|
+
? 'medium'
|
|
107
|
+
: found.has('minor')
|
|
108
|
+
? 'minor'
|
|
109
|
+
: found.has('suggestion')
|
|
110
|
+
? 'suggestion'
|
|
111
|
+
: 'none';
|
|
112
|
+
// Determine verdict from text
|
|
113
|
+
const needsRevision = lower.includes('needs revision');
|
|
114
|
+
const readyToApprove = lower.includes('ready to approve');
|
|
115
|
+
// Severity markers win over verdict text when they disagree.
|
|
116
|
+
// A "Ready to approve" verdict with blocking-severity findings is contradictory —
|
|
117
|
+
// trust the severity markers.
|
|
118
|
+
if (markerSeverity !== 'none') {
|
|
119
|
+
const shouldLoop = markerSeverity === 'blocking';
|
|
120
|
+
return { maxSeverity: markerSeverity, shouldLoop };
|
|
121
|
+
}
|
|
122
|
+
// No severity markers found — fall back to verdict text
|
|
123
|
+
if (needsRevision) {
|
|
124
|
+
return { maxSeverity: 'blocking', shouldLoop: true };
|
|
125
|
+
}
|
|
126
|
+
if (readyToApprove) {
|
|
127
|
+
return { maxSeverity: 'minor', shouldLoop: false };
|
|
128
|
+
}
|
|
129
|
+
// Malformed output — stop and let the human review
|
|
130
|
+
return { maxSeverity: 'none', shouldLoop: false };
|
|
131
|
+
}
|
|
132
|
+
export function parseAuditVerdict(auditText) {
|
|
133
|
+
if (!auditText || !auditText.trim()) {
|
|
134
|
+
return { maxSeverity: 'none', shouldLoop: false };
|
|
135
|
+
}
|
|
136
|
+
const parsedJson = tryParseJsonVerdict(auditText);
|
|
137
|
+
if (parsedJson)
|
|
138
|
+
return parsedJson;
|
|
139
|
+
return parseAuditVerdictLegacy(auditText);
|
|
140
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const SEVERITY_LABELS = {
|
|
2
|
+
blocking: 'blocking severity',
|
|
3
|
+
medium: 'medium severity',
|
|
4
|
+
minor: 'minor severity',
|
|
5
|
+
suggestion: 'suggestion-level',
|
|
6
|
+
};
|
|
7
|
+
export async function autoImplementForgePlan(opts, deps) {
|
|
8
|
+
const { planId, result } = opts;
|
|
9
|
+
const { planApprove, planRun, isPlanRunning, log } = deps;
|
|
10
|
+
const verdict = result.finalVerdict;
|
|
11
|
+
const normalizedVerdict = typeof verdict === 'string' ? verdict.toLowerCase() : '';
|
|
12
|
+
const severity = normalizedVerdict && normalizedVerdict !== 'none' ? normalizedVerdict : undefined;
|
|
13
|
+
if (!planId) {
|
|
14
|
+
return manualOutcome('', 'Plan ID missing from the forge output.');
|
|
15
|
+
}
|
|
16
|
+
if (result.error) {
|
|
17
|
+
return manualOutcome(planId, `Forge failed: ${result.error}`);
|
|
18
|
+
}
|
|
19
|
+
if (result.reachedMaxRounds) {
|
|
20
|
+
return manualOutcome(planId, 'Forge reached the audit cap (CAP_REACHED) and left concerns unresolved. Manual review is required.');
|
|
21
|
+
}
|
|
22
|
+
if (!verdict || verdict === 'CANCELLED' || verdict === 'error') {
|
|
23
|
+
return manualOutcome(planId, 'Forge did not emit a ready verdict. Please inspect the plan before proceeding.');
|
|
24
|
+
}
|
|
25
|
+
if (normalizedVerdict === 'blocking') {
|
|
26
|
+
return manualOutcome(planId, 'Review the flagged concerns before implementing.', normalizedVerdict);
|
|
27
|
+
}
|
|
28
|
+
if (isPlanRunning(planId)) {
|
|
29
|
+
return manualOutcome(planId, 'A plan run is already in progress for this plan.');
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
await planApprove(planId);
|
|
33
|
+
log?.info({ planId }, 'forge:auto-implement: plan auto-approved');
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const reason = `Auto-approval failed: ${String(err)}`;
|
|
37
|
+
log?.error({ err, planId }, 'forge:auto-implement: approval failed');
|
|
38
|
+
return manualOutcome(planId, reason);
|
|
39
|
+
}
|
|
40
|
+
let runSummary;
|
|
41
|
+
try {
|
|
42
|
+
({ summary: runSummary } = await planRun(planId));
|
|
43
|
+
log?.info({ planId }, 'forge:auto-implement: implementation run completed');
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const reason = `Auto-run failed: ${String(err)}`;
|
|
47
|
+
log?.error({ err, planId }, 'forge:auto-implement: run failed');
|
|
48
|
+
return manualOutcome(planId, reason);
|
|
49
|
+
}
|
|
50
|
+
const warningMessage = severity ? `Forge reported ${severityLabel(severity)} concerns.` : undefined;
|
|
51
|
+
const summaryParts = [];
|
|
52
|
+
if (warningMessage)
|
|
53
|
+
summaryParts.push(warningMessage);
|
|
54
|
+
if (runSummary)
|
|
55
|
+
summaryParts.push(runSummary);
|
|
56
|
+
const summary = summaryParts.join('\n\n');
|
|
57
|
+
return { status: 'auto', planId, summary };
|
|
58
|
+
}
|
|
59
|
+
function manualOutcome(planId, reason, severity) {
|
|
60
|
+
const messageParts = [];
|
|
61
|
+
if (severity && severity !== 'none') {
|
|
62
|
+
messageParts.push(`Forge reported ${severityLabel(severity)} concerns.`);
|
|
63
|
+
}
|
|
64
|
+
if (reason) {
|
|
65
|
+
messageParts.push(reason);
|
|
66
|
+
}
|
|
67
|
+
if (planId) {
|
|
68
|
+
messageParts.push(`Reply \`!plan approve ${planId}\` to approve, then \`!plan run ${planId}\` to start implementation. Or \`!plan show ${planId}\` to review first.`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
messageParts.push('Review the plan manually, then use `!plan approve <id>` and `!plan run <id>` to continue.');
|
|
72
|
+
}
|
|
73
|
+
return { status: 'manual', message: messageParts.join(' ') };
|
|
74
|
+
}
|
|
75
|
+
function severityLabel(value) {
|
|
76
|
+
if (value in SEVERITY_LABELS) {
|
|
77
|
+
return SEVERITY_LABELS[value];
|
|
78
|
+
}
|
|
79
|
+
return `${value} severity`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { autoImplementForgePlan } from './forge-auto-implement.js';
|
|
3
|
+
const baseResult = {
|
|
4
|
+
planId: 'plan-001',
|
|
5
|
+
filePath: 'plans/plan-001.md',
|
|
6
|
+
finalVerdict: 'none',
|
|
7
|
+
rounds: 1,
|
|
8
|
+
reachedMaxRounds: false,
|
|
9
|
+
};
|
|
10
|
+
const createDeps = (overrides = {}) => {
|
|
11
|
+
const log = {
|
|
12
|
+
info: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
};
|
|
16
|
+
const base = {
|
|
17
|
+
planApprove: vi.fn(() => Promise.resolve()),
|
|
18
|
+
planRun: vi.fn(() => Promise.resolve({ summary: 'Plan run started' })),
|
|
19
|
+
isPlanRunning: vi.fn(() => false),
|
|
20
|
+
log,
|
|
21
|
+
};
|
|
22
|
+
return { ...base, ...overrides };
|
|
23
|
+
};
|
|
24
|
+
const buildResult = (overrides = {}) => ({
|
|
25
|
+
...baseResult,
|
|
26
|
+
...overrides,
|
|
27
|
+
});
|
|
28
|
+
describe('autoImplementForgePlan', () => {
|
|
29
|
+
it('auto approves and runs a clean plan', async () => {
|
|
30
|
+
const result = buildResult();
|
|
31
|
+
const deps = createDeps();
|
|
32
|
+
const outcome = await autoImplementForgePlan({ planId: result.planId, result }, deps);
|
|
33
|
+
expect(outcome).toEqual({ status: 'auto', planId: result.planId, summary: 'Plan run started' });
|
|
34
|
+
expect(deps.planApprove).toHaveBeenCalledWith(result.planId);
|
|
35
|
+
expect(deps.planRun).toHaveBeenCalledWith(result.planId);
|
|
36
|
+
expect(deps.log?.info).toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
it('auto approves and runs a plan with non-blocking severity warnings', async () => {
|
|
39
|
+
const result = buildResult({ finalVerdict: 'medium' });
|
|
40
|
+
const deps = createDeps();
|
|
41
|
+
const outcome = await autoImplementForgePlan({ planId: result.planId, result }, deps);
|
|
42
|
+
expect(outcome.status).toBe('auto');
|
|
43
|
+
if (outcome.status !== 'auto')
|
|
44
|
+
throw new Error('expected auto');
|
|
45
|
+
expect(outcome.planId).toBe(result.planId);
|
|
46
|
+
expect(outcome.summary).toBe('Forge reported medium severity concerns.\n\nPlan run started');
|
|
47
|
+
expect(deps.planApprove).toHaveBeenCalledWith(result.planId);
|
|
48
|
+
expect(deps.planRun).toHaveBeenCalledWith(result.planId);
|
|
49
|
+
expect(deps.log?.info).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
it('warns when a plan run is already in progress', async () => {
|
|
52
|
+
const result = buildResult();
|
|
53
|
+
const deps = createDeps({ isPlanRunning: () => true });
|
|
54
|
+
const outcome = await autoImplementForgePlan({ planId: result.planId, result }, deps);
|
|
55
|
+
expect(outcome.status).toBe('manual');
|
|
56
|
+
if (outcome.status !== 'manual')
|
|
57
|
+
throw new Error('expected manual');
|
|
58
|
+
expect(outcome.message).toContain('A plan run is already in progress for this plan.');
|
|
59
|
+
});
|
|
60
|
+
it('notifies when the audit cap was reached', async () => {
|
|
61
|
+
const result = buildResult({ reachedMaxRounds: true });
|
|
62
|
+
const deps = createDeps();
|
|
63
|
+
const outcome = await autoImplementForgePlan({ planId: result.planId, result }, deps);
|
|
64
|
+
expect(outcome.status).toBe('manual');
|
|
65
|
+
if (outcome.status !== 'manual')
|
|
66
|
+
throw new Error('expected manual');
|
|
67
|
+
expect(outcome.message).toContain('CAP_REACHED');
|
|
68
|
+
});
|
|
69
|
+
it('propagates forge errors into the fallback message', async () => {
|
|
70
|
+
const result = buildResult({ error: 'timeout' });
|
|
71
|
+
const deps = createDeps();
|
|
72
|
+
const outcome = await autoImplementForgePlan({ planId: result.planId, result }, deps);
|
|
73
|
+
expect(outcome.status).toBe('manual');
|
|
74
|
+
if (outcome.status !== 'manual')
|
|
75
|
+
throw new Error('expected manual');
|
|
76
|
+
expect(outcome.message).toContain('Forge failed: timeout');
|
|
77
|
+
});
|
|
78
|
+
it('falls back when auto-approval throws', async () => {
|
|
79
|
+
const result = buildResult();
|
|
80
|
+
const planApprove = vi.fn(() => Promise.reject(new Error('boom')));
|
|
81
|
+
const deps = createDeps({ planApprove });
|
|
82
|
+
const outcome = await autoImplementForgePlan({ planId: result.planId, result }, deps);
|
|
83
|
+
expect(outcome.status).toBe('manual');
|
|
84
|
+
if (outcome.status !== 'manual')
|
|
85
|
+
throw new Error('expected manual');
|
|
86
|
+
expect(outcome.message).toContain('Auto-approval failed: Error: boom');
|
|
87
|
+
expect(deps.planRun).not.toHaveBeenCalled();
|
|
88
|
+
expect(deps.log?.error).toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
it('logs and reports when the plan run fails', async () => {
|
|
91
|
+
const result = buildResult();
|
|
92
|
+
const planRun = vi.fn(() => Promise.reject(new Error('run fail')));
|
|
93
|
+
const deps = createDeps({ planRun });
|
|
94
|
+
const outcome = await autoImplementForgePlan({ planId: result.planId, result }, deps);
|
|
95
|
+
expect(outcome.status).toBe('manual');
|
|
96
|
+
if (outcome.status !== 'manual')
|
|
97
|
+
throw new Error('expected manual');
|
|
98
|
+
expect(outcome.message).toContain('Auto-run failed: Error: run fail');
|
|
99
|
+
expect(deps.log?.error).toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
it('includes blocking severity labels in the fallback message', async () => {
|
|
102
|
+
const result = buildResult({ finalVerdict: 'blocking' });
|
|
103
|
+
const deps = createDeps();
|
|
104
|
+
const outcome = await autoImplementForgePlan({ planId: result.planId, result }, deps);
|
|
105
|
+
expect(outcome.status).toBe('manual');
|
|
106
|
+
if (outcome.status !== 'manual')
|
|
107
|
+
throw new Error('expected manual');
|
|
108
|
+
expect(outcome.message).toContain('blocking severity concerns');
|
|
109
|
+
});
|
|
110
|
+
});
|