/**
 * The feedback/guardrails readiness producer — ship blocker B4 (PRD #1739,
 * #1896).
 *
 * Dimension 5 of the `readiness-rubric` asks whether a failing loop produces a
 * named outcome and a runbook. B4 asks the operational half: does every
 * irreversible or expensive operation have a gate AND a way back?
 *
 * Only part of that is answerable offline, and saying so plainly is the point.
 * Whether a declared GitHub `environment:` actually carries protection rules —
 * required reviewers, wait timers, branch restrictions — lives in the GitHub API,
 * not in any file this producer can read. That half is therefore a **stated
 * SKIP in every record emitted here**, never a guess in either direction.
 *
 * What remains is a narrow, file-provable subset: a job that runs an
 * irreversible or expensive operation with **no `environment:` key at all**, no
 * `if:` gate, reachable from an automated trigger, in a repository that checks
 * in no runbook. Only that stands B4. Everything else — an ordinary `cdk
 * deploy`, a CI-database migration, a job that names any environment, a reusable
 * workflow whose caller is invisible — is an observation at most, because a
 * standing blocker flips the whole repository to `NOT_READY` and `WARN` does not
 * soften that.
 * @module cli/doctor-readiness-guardrails
 */
import { commandTargetsOwnedService, describeCommands, hasCheckedInRunbook, looksEphemeral, scanCommands, } from "./doctor-readiness-operations.js";
import { expandLocalScriptCommandsWithDiagnostics } from "./doctor-readiness-local-scripts.js";
import { informationalFindings } from "./doctor-readiness-shared.js";
import { parseRepositoryWorkflows, } from "./doctor-readiness-workflows.js";
/** The feedback/guardrails readiness dimension id (readiness-rubric, RRR-1). */
export const FEEDBACK_GUARDRAILS_DIMENSION_ID = "feedback-guardrails";
/** The ship blocker for a consequential operation with no gate and no recovery. */
const GUARDRAILS_BLOCKER_ID = "B4";
/** Most operations quoted in one finding before summarizing the remainder. */
const MAX_EVIDENCE_COMMANDS = 10;
/**
 * The half of B4 no offline read can settle, stated in every record this
 * producer emits so an operator is never left to infer that a declared
 * environment was checked for protection rules. It was not.
 */
const PROTECTION_RULES_SKIP_REASON = "Whether a declared `environment:` actually gates anything — required " +
    "reviewers, wait timers, branch restrictions — is a GitHub environment " +
    "protection rule, which lives in the GitHub API and is not resolvable " +
    "offline. That half of this question was deliberately not assessed; only the " +
    "file-provable half (an irreversible or expensive operation with no " +
    "`environment:` key at all and no checked-in runbook) was.";
/**
 * Operations that are irreversible or expensive enough that running them
 * unattended is a decision, not a detail. `cdk deploy` and a plain `terraform
 * apply` are absent on purpose: they are how normal delivery works, and a
 * blocker that fires on normal delivery is a blocker nobody will believe.
 */
const CONSEQUENTIAL_OPS = [
    /\bterraform\s+(\S+\s+)*destroy\b/,
    /\bcdk\s+destroy\b/,
    /\bpulumi\s+destroy\b/,
    /\bpulumi\s+up\b[^\n]*--yes\b/,
    /\bserverless\s+remove\b/,
];
/** A Terraform apply command, consequential only when auto-approval is present. */
const TERRAFORM_APPLY = /\bterraform(?:\s+\S+)*\s+apply\b/;
/** Terraform's non-interactive approval flag. */
const TERRAFORM_AUTO_APPROVE = /(^|\s)-auto-approve\b/;
/** Terraform env vars that inject CLI args into every command. */
const TERRAFORM_ARGS_AUTO_APPROVE = /\b(tf_cli_args(?:_apply)?|tf_args)\s*[:=][^\n]*-auto-approve\b/;
/** Separators that end one shell invocation for this conservative scan. */
const SHELL_INVOCATION_SEPARATOR = /\n|;|&&|\|\|/;
/**
 * Commands that apply schema migrations, in the ecosystems Lisa templates. The
 * `(?!:)` lookahead keeps the read-only subcommands out: `db:migrate:status`
 * and `db:migrate:version` report what has run, they do not run anything.
 */
const MIGRATION_COMMANDS = [
    /\bdb:migrate\b(?!:)/,
    /\bmigrate\s+deploy\b/,
    /\bmigration:run\b/,
    /\balembic\s+upgrade\b/,
];
/**
 * Markers that a command explicitly targets production. A migration is only
 * consequential when the file itself says it runs against production: the same
 * command against a CI service container is ordinary test setup, and treating
 * the two alike is the false positive that would discredit this whole check.
 */
const PRODUCTION_MARKERS = [
    /\b(rails_env|node_env|app_env|django_settings_module|environment)\s*[:=]\s*["']?prod(?:uction)?["']?(?=\s|$)/,
    /--env(ironment)?[= ]prod/,
    /\b(?:database_url|db_url|url)\s*[:=]\s*\$\{\{\s*secrets\.(?:[a-z0-9]+_)*(?:prod|production)(?:_[a-z0-9]+)*\s*\}\}/,
    /\bprod(uction)?\.tfvars\b/,
];
/**
 * Action/reusable-workflow evidence that may hide consequential behavior behind
 * an opaque implementation this offline scanner cannot inspect.
 */
const OPAQUE_CONSEQUENTIAL_ACTIONS = [
    /\bterraform\b/,
    /\bpulumi\b/,
    /\bserverless\b/,
    /\bdeploy\b/,
    /\bdestroy\b/,
    /\bmigrat(e|ion)\b/,
];
/** Inputs that make an opaque action/reusable workflow look consequential. */
const OPAQUE_CONSEQUENTIAL_INPUTS = [
    /\bdestroy\b/,
    /\bremove\b/,
    /\bdelete\b/,
    /\bapply\b[^\n]*-auto-approve\b/,
    /\bprod(uction)?\b/,
];
/** Local wrapper names that are explicit enough to count as consequential. */
const LOCAL_CONSEQUENTIAL_WRAPPERS = [
    /(?:^|\s)(?:\.\/|bash\s+|sh\s+)(?:scripts\/)?[a-z0-9._/-]*(?:destroy|delete|remove|teardown|migrate|reset)[a-z0-9._/-]*(?:prod|production)[a-z0-9._/-]*/,
    /(?:^|\s)(?:\.\/|bash\s+|sh\s+)(?:scripts\/)?[a-z0-9._/-]*(?:prod|production)[a-z0-9._/-]*(?:destroy|delete|remove|teardown|migrate|reset)[a-z0-9._/-]*/,
    /\b(?:npm|yarn|pnpm|bun)\s+run\s+[a-z0-9:_-]*(?:destroy|delete|remove|teardown|migrate|reset)[a-z0-9:_-]*(?:prod|production)[a-z0-9:_-]*/,
    /\b(?:npm|yarn|pnpm|bun)\s+run\s+[a-z0-9:_-]*(?:prod|production)[a-z0-9:_-]*(?:destroy|delete|remove|teardown|migrate|reset)[a-z0-9:_-]*/,
];
/**
 * Collapse only shell continuation newlines. Ordinary newlines remain command
 * boundaries so unrelated invocations cannot combine into one Terraform apply.
 * @param command - Shell command text
 * @returns Command text with escaped newlines joined
 */
function normalizeShellContinuations(command) {
    return command.replace(/\\\r?\n\s*/g, " ");
}
/**
 * Extract shell invocations that contain `terraform apply`.
 * @param command - Shell command text
 * @returns Invocation fragments that include Terraform apply
 */
function terraformApplyInvocations(command) {
    return normalizeShellContinuations(command)
        .split(SHELL_INVOCATION_SEPARATOR)
        .map(part => part.trim())
        .filter(part => TERRAFORM_APPLY.test(part));
}
/**
 * Check whether a Terraform apply invocation targets a saved plan file.
 * @param invocation - Single shell invocation
 * @returns True when `apply` has a positional plan-file argument
 */
function appliesSavedTerraformPlan(invocation) {
    const tokens = invocation.split(/\s+/).filter(Boolean);
    const terraformIndex = tokens.findIndex(token => token === "terraform");
    if (terraformIndex === -1) {
        return false;
    }
    const applyIndex = tokens.indexOf("apply", terraformIndex + 1);
    if (applyIndex === -1) {
        return false;
    }
    return tokens
        .slice(applyIndex + 1)
        .some(token => token !== "\\" && !token.startsWith("-"));
}
/**
 * Whether a command is an irreversible or expensive operation.
 *
 * The ephemeral screen is the same one B1 uses, widened to the per-pull-request
 * markers this blocker meets constantly: tearing down `pr-1234` or a
 * `PreviewStack` destroys what the same pipeline built minutes earlier, and
 * there is nothing durable there for a gate to protect. The job's `env:` block
 * is read alongside the command, since the stage name usually lives there.
 * @param command - The lower-cased `run` command
 * @param job - The job the command runs in
 * @param step - The step the command runs in
 * @returns True when the command is consequential on its face
 */
function isConsequential(command, job, step) {
    const targetEvidence = `${command}\n${step.env}\n${job.env}`.toLowerCase();
    if (looksEphemeral(targetEvidence)) {
        return false;
    }
    const terraformApplyInvocationsToAssess = terraformApplyInvocations(command);
    const terraformAutoApprovedApply = terraformApplyInvocationsToAssess.some(invocation => TERRAFORM_AUTO_APPROVE.test(invocation) ||
        appliesSavedTerraformPlan(invocation)) ||
        (terraformApplyInvocationsToAssess.length > 0 &&
            TERRAFORM_ARGS_AUTO_APPROVE.test(targetEvidence));
    return (terraformAutoApprovedApply ||
        CONSEQUENTIAL_OPS.some(pattern => pattern.test(command)) ||
        LOCAL_CONSEQUENTIAL_WRAPPERS.some(pattern => pattern.test(command)) ||
        (MIGRATION_COMMANDS.some(pattern => pattern.test(command)) &&
            PRODUCTION_MARKERS.some(pattern => pattern.test(targetEvidence))));
}
/**
 * Triggers that fire on the lifecycle of a short-lived resource rather than on
 * a release. A workflow that runs on a closing pull request or a deleted branch
 * is tearing down what that branch created.
 */
const EPHEMERAL_LIFECYCLE_EVENTS = [
    "pull_request",
    "pull_request_target",
    "delete",
];
/**
 * State why a workflow driven by a branch or pull-request lifecycle cannot be
 * settled offline. This is deferred rather than cleared: the operation is real,
 * but whether it touches anything durable is not provable from the file.
 * @param workflow - The workflow to consider
 * @returns A stated reason, or null when the trigger is a release-style one
 */
function lifecycleDeferral(workflow) {
    const events = workflow.on.events.filter(event => EPHEMERAL_LIFECYCLE_EVENTS.includes(event));
    if (events.length === 0 || events.length < workflow.on.events.length) {
        return null;
    }
    return (`\`${workflow.file}\` runs a consequential operation on the \`${events.join(", ")}\` ` +
        "lifecycle, which tears down what that same branch or pull request created, " +
        "so whether it ever touches a durable environment is not established " +
        "either way — no gate was expected and none was required");
}
/**
 * State why a service-targeted consequential operation cannot be settled. A job
 * may run against service containers it created for that same CI run, but an
 * unrelated service must not excuse a durable infrastructure operation.
 * @param _workflow - The workflow declaring the job; unused for this deferral
 * @param job - The job to consider
 * @param step - The consequential step being classified
 * @returns A stated reason, or null when the service does not explain the step
 */
function servicesDeferral(_workflow, job, step) {
    if (!commandTargetsOwnedService(job, step)) {
        return null;
    }
    return (`Job \`${job.id}\` runs a consequential operation while starting its own ` +
        `\`services:\` container(s) (${job.services.join(", ")}), which are created ` +
        "and thrown away inside the run, so whether the operation targets a durable " +
        "environment is not established either way");
}
/**
 * Whether an opaque action/reusable call has enough local text to be named as
 * an unassessed consequential surface.
 * @param id - The `uses:` target
 * @param inputs - Serialized `with:`/`env:` text adjacent to the call
 * @returns True when the call should be surfaced as unresolved
 */
function looksLikeOpaqueConsequentialCall(id, inputs) {
    const evidence = `${id}\n${inputs}`.toLowerCase();
    return (OPAQUE_CONSEQUENTIAL_ACTIONS.some(pattern => pattern.test(evidence)) &&
        OPAQUE_CONSEQUENTIAL_INPUTS.some(pattern => pattern.test(evidence)) &&
        !looksEphemeral(evidence));
}
/**
 * State why a reusable workflow call may hide consequential behavior this file
 * cannot inspect.
 * @param workflow - The workflow declaring the job
 * @param job - The reusable workflow job
 * @returns A stated reason, or null when the call does not look consequential
 */
function reusableCallDeferral(workflow, job) {
    if (job.uses === "" ||
        !looksLikeOpaqueConsequentialCall(job.uses, job.inputs)) {
        return null;
    }
    return (`\`${workflow.file}\` job \`${job.id}\` calls reusable workflow ` +
        `\`${job.uses}\` with consequential-looking inputs; this offline read ` +
        "cannot inspect the called workflow implementation, so whether it runs an " +
        "irreversible or expensive operation is not established either way");
}
/**
 * State why an action step may hide consequential behavior this file cannot
 * inspect.
 * @param workflow - The workflow declaring the job
 * @param job - The job declaring the action step
 * @param step - The action step
 * @returns A stated reason, or null when the action does not look consequential
 */
function actionStepDeferral(workflow, job, step) {
    if (step.uses === "" ||
        !looksLikeOpaqueConsequentialCall(step.uses, step.inputs)) {
        return null;
    }
    return (`\`${workflow.file}\` job \`${job.id}\` uses action ` +
        `\`${step.uses}\` with consequential-looking inputs; this offline read ` +
        "cannot inspect the action implementation, so whether it runs an " +
        "irreversible or expensive operation is not established either way");
}
/**
 * Build the rubric-shaped B4 finding from the operations it cites.
 * @param scan - The consequential-operation scan
 * @returns The B4 finding
 */
function guardrailsFinding(scan) {
    return {
        blocker: GUARDRAILS_BLOCKER_ID,
        invariant_violated: "every irreversible or expensive operation has a gate before it and a " +
            "way back after it",
        evidence: describeCommands(scan.ungated, MAX_EVIDENCE_COMMANDS),
        why_proof_missed: "the job declares no `environment:` at all, so there is nothing for a " +
            "protection rule to attach to and nothing for an operator to approve — " +
            "and with no runbook checked in, the first time anyone works out how to " +
            "undo the operation is while it is already failing",
        root_correction: "declare an `environment:` on the job so a protection rule can gate it, " +
            "and check in a runbook naming the recovery procedure and the run " +
            "outcome the loop reports, per the `automation-runbook-contract` rule",
        machinery_to_remove: [
            "auto-approval flags that exist only because no approval gate was ever " +
                "configured for the environment the job deploys to",
        ],
    };
}
/**
 * Build the SKIP record. Its first finding always states the network-bound half
 * that was not assessed; the observations below it carry whatever consequential
 * operations were found to be already gated.
 * @param scan - The consequential-operation scan
 * @param inspected - How many workflow files were read
 * @param runbook - Whether a runbook is checked in
 * @returns The SKIP dimension record
 */
function skipRecord(scan, inspected, runbook) {
    const reason = inspected === 0
        ? `No GitHub Actions workflow files were found under \`.github/workflows/\`, ` +
            `so no irreversible or expensive operations could be assessed from ` +
            `workflow declarations (recovery runbook ` +
            `${runbook ? "present" : "absent"}). ${PROTECTION_RULES_SKIP_REASON}`
        : `Read ${inspected} workflow file(s) for irreversible or expensive ` +
            "operations and found none that this file alone proves runs " +
            `unattended with no gate and no way back (recovery runbook ` +
            `${runbook ? "present" : "absent"}). ${PROTECTION_RULES_SKIP_REASON}`;
    return {
        id: FEEDBACK_GUARDRAILS_DIMENSION_ID,
        status: "SKIP",
        findings: [
            {
                reason,
                skip: true,
            },
            ...informationalFindings([
                ...scan.gated.map(entry => `\`${entry.workflow}\` job \`${entry.jobId}\` runs a consequential ` +
                    `operation (\`${entry.command}\`) behind a declared environment, an ` +
                    "`if:` condition, its own confirmation switch, or in a repository " +
                    "that checks in a recovery runbook, so it was not treated as " +
                    "ungated and unrecoverable — whether any such gate carries " +
                    "protection rules was not checked"),
                ...scan.unresolved,
            ]),
        ],
    };
}
/**
 * Assess the feedback/guardrails dimension: B4, "a consequential operation has
 * no gate and no recovery". Offline by construction, and honest about it — the
 * environment-protection-rule half is reasoned-SKIPped in every record, so this
 * dimension never reports `PASS`. B4 stands only on the file-provable subset.
 * @param root - Project root to assess
 * @param parsedWorkflows - Pre-parsed workflows (default: parse `root`)
 * @returns The feedback/guardrails dimension record
 */
export async function assessFeedbackGuardrailsDimension(root, parsedWorkflows) {
    const parsed = parsedWorkflows ?? (await parseRepositoryWorkflows(root));
    const expanded = await expandLocalScriptCommandsWithDiagnostics(root, parsed);
    const workflows = expanded.value;
    const scanned = scanCommands(workflows, isConsequential, {
        unresolvedWorkflow: lifecycleDeferral,
        unresolvedStep: servicesDeferral,
        unresolvedJobCall: reusableCallDeferral,
        unresolvedActionStep: actionStepDeferral,
    });
    const scan = {
        ...scanned,
        unresolved: [...scanned.unresolved, ...expanded.unresolved],
    };
    const runbook = await hasCheckedInRunbook(root);
    if (scan.ungated.length === 0 || runbook) {
        return skipRecord(runbook ? { ...scan, gated: [...scan.gated, ...scan.ungated] } : scan, workflows.length, runbook);
    }
    return {
        id: FEEDBACK_GUARDRAILS_DIMENSION_ID,
        // `WARN` reports how much confidence an offline read can carry; it does NOT
        // soften the verdict. The blocker engine never reads this status.
        status: "WARN",
        findings: [
            guardrailsFinding(scan),
            // The unassessable half must travel with the standing blocker too:
            // dropping it here would let a reader believe the gate was checked.
            ...informationalFindings([
                PROTECTION_RULES_SKIP_REASON,
                ...scan.unresolved,
            ]),
        ],
    };
}
//# sourceMappingURL=doctor-readiness-guardrails.js.map