TypeScriptCommentRedactor.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.discovery.typescript;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.egothor.methodatlas.api.CommentRedactionException;
import org.egothor.methodatlas.api.CommentRedactor;
import org.egothor.methodatlas.util.CommentBlanker;
import org.egothor.methodatlas.util.WorkerCircuitBreaker;

/**
 * {@link CommentRedactor} for TypeScript and JavaScript source text, built on
 * the {@code @typescript-eslint/typescript-estree} parser that already runs in
 * the Node.js scanner workers.
 *
 * <p>
 * A worker parses the text exactly as given and reports the half-open UTF-16
 * range of every comment: line comments, block comments and JSDoc blocks, plus
 * a leading hashbang line, which the parser skips without reporting. This class
 * blanks those ranges with {@link CommentBlanker} (spaces, line terminators
 * kept), so the length invariant and the fail-closed decision live on the Java
 * side. Because a real parser classifies the text, comment markers inside
 * string literals, template literals (including their {@code ${...}}
 * substitutions), regular-expression literals and JSX text are never touched.
 * </p>
 *
 * <p>
 * The redactor fails closed. A {@link CommentRedactionException} is thrown if
 * the text does not parse, if the worker fails, times out or reports a text
 * length different from ours, if a range is invalid, or if parsing the result
 * again still finds a comment. Only {@code '\n'} and {@code '\r'} inside a
 * comment are guaranteed to be kept; the Unicode line separators U+2028 and
 * U+2029 inside a block comment become spaces like any other comment character.
 * </p>
 *
 * <h2>Node.js availability</h2>
 * <p>
 * The redactor needs the same Node.js (version 18 or later) and the same
 * verified bundle as discovery. {@link #supports(Path)} therefore returns
 * {@code false} for TypeScript and JavaScript files when Node.js or the bundle
 * is unusable, so {@code -redact-comments} aborts for those files instead of
 * sending them un-redacted. {@code node --version} is only run when the first
 * TypeScript or JavaScript file is offered, never for other languages.
 * </p>
 *
 * <p>
 * Instances own a lazily created worker pool that is stopped by a JVM shutdown
 * hook; they are safe for concurrent use.
 * </p>
 *
 * @see CommentRedactor
 * @since 4.1.0
 */
public final class TypeScriptCommentRedactor implements CommentRedactor {

    private static final Logger LOG = Logger.getLogger(TypeScriptCommentRedactor.class.getName());

    /** Every suffix the TypeScript plugin family treats as TypeScript or JavaScript source. */
    private static final List<String> SUFFIXES =
            List.of(".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs");

    private static final int POOL_SIZE = Math.min(4, Runtime.getRuntime().availableProcessors());
    private static final long WORKER_TIMEOUT_MILLIS = 30_000L;
    private static final int MAX_RESTARTS = 5;
    private static final int RESTART_WINDOW_SEC = 60;

    private final Supplier<NodeEnvironment> environmentFactory;
    private final ReentrantLock initLock = new ReentrantLock();
    private boolean initialised;
    private TypeScriptWorkerPool pool;
    private Path sandboxRoot;

    /**
     * Creates a redactor; required by {@link java.util.ServiceLoader}. Node.js
     * is detected lazily.
     */
    public TypeScriptCommentRedactor() {
        this(NodeEnvironment::new);
    }

    /**
     * Creates a redactor with an injected Node.js detector.
     *
     * @param environmentFactory supplies the Node.js environment; called at
     *                           most once
     */
    /* default */ TypeScriptCommentRedactor(Supplier<NodeEnvironment> environmentFactory) {
        this.environmentFactory = environmentFactory;
    }

    /**
     * Returns the identifier of this redactor, equal to that of
     * {@link TypeScriptTestDiscovery#pluginId()}.
     *
     * @return {@code "typescript"}
     */
    @Override
    public String pluginId() {
        return "typescript";
    }

    /**
     * Tells whether this redactor can redact {@code sourceFile}: its name ends
     * in a TypeScript or JavaScript suffix (case-insensitive) <em>and</em>
     * Node.js and the verified scanner bundle are usable. Configured discovery
     * suffixes are deliberately not consulted.
     *
     * @param sourceFile path to test; may be {@code null}
     * @return {@code true} if this redactor can handle the file right now
     */
    @Override
    public boolean supports(Path sourceFile) {
        Path fileName = sourceFile == null ? null : sourceFile.getFileName();
        if (fileName == null) {
            return false;
        }
        String name = fileName.toString().toLowerCase(Locale.ROOT);
        return SUFFIXES.stream().anyMatch(name::endsWith) && ensureReady();
    }

    /**
     * Replaces every comment in {@code source} with spaces.
     *
     * @param sourceFile file the text belongs to; its extension selects JSX
     *                   handling, the file is not read
     * @param source     TypeScript or JavaScript source text; must not be
     *                   {@code null}
     * @return text of identical length in which every comment character other
     *         than a line terminator is a space; never {@code null}
     * @throws CommentRedactionException if the text cannot be parsed, the
     *                                   worker fails, or the redaction cannot
     *                                   be guaranteed complete
     */
    @Override
    public String redact(Path sourceFile, String source) throws CommentRedactionException {
        if (!ensureReady()) {
            throw new CommentRedactionException(
                    "Node.js 18+ and the TypeScript scanner bundle are required to redact " + sourceFile);
        }
        TypeScriptWorker.RedactionRanges found = locate(sourceFile, source);
        if (found.ranges().isEmpty()) {
            return source;
        }
        CommentBlanker blanker = new CommentBlanker(source);
        for (TypeScriptWorker.CommentRange range : found.ranges()) {
            blanker.blankChars(range.start(), range.end());
        }
        String result = blanker.result();
        if (result.length() != source.length()) {
            throw new CommentRedactionException("redaction changed the length of " + sourceFile);
        }
        if (!locate(sourceFile, result).ranges().isEmpty()) {
            throw new CommentRedactionException("a comment survived redaction in " + sourceFile);
        }
        return result;
    }

    /**
     * Asks a worker for the comment ranges of {@code text} and checks the
     * answer is trustworthy.
     */
    private TypeScriptWorker.RedactionRanges locate(Path sourceFile, String text)
            throws CommentRedactionException {
        TypeScriptWorker.RedactionRanges answer;
        try {
            answer = pool.redact(sourceFile, text, sandboxRoot);
        } catch (IOException e) {
            throw new CommentRedactionException("TypeScript worker failed for " + sourceFile + ": " + e.getMessage(),
                    e);
        }
        if (answer.error() != null) {
            throw new CommentRedactionException("cannot parse " + sourceFile + ": " + answer.error());
        }
        if (answer.length() != text.length()) {
            throw new CommentRedactionException("worker saw " + answer.length() + " characters of " + sourceFile
                    + " but " + text.length() + " were sent");
        }
        return answer;
    }

    /**
     * Detects Node.js and starts the pool once.
     *
     * @return {@code true} if redaction can run
     */
    private boolean ensureReady() {
        initLock.lock();
        try {
            if (!initialised) {
                initialised = true;
                initialise();
            }
            return pool != null;
        } finally {
            initLock.unlock();
        }
    }

    private void initialise() {
        NodeEnvironment env = environmentFactory.get();
        if (env == null || !env.isAvailable()) {
            LOG.log(Level.WARNING, "-redact-comments cannot redact TypeScript/JavaScript files: Node.js 18 or later is not available "
                    + "(or is too old); the run will abort rather than send un-redacted source to the AI provider. "
                    + "Install Node.js 18 or later or exclude these files from the scan.");
            return;
        }
        try {
            Path bundle = BundleIntegrity.extractAndVerify();
            WorkerCircuitBreaker breaker = new WorkerCircuitBreaker("TypeScript-redact", "typescript-redact",
                    MAX_RESTARTS, RESTART_WINDOW_SEC);
            pool = new TypeScriptWorkerPool(bundle, env, POOL_SIZE, WORKER_TIMEOUT_MILLIS, breaker);
            // Redaction never reads files. Handing the bundle itself as the sandbox
            // "root" makes the worker's allow-fs-read grant cover the bundle only.
            sandboxRoot = bundle;
        } catch (IOException | IllegalStateException e) {
            LOG.log(Level.SEVERE, "Failed to initialise TypeScript comment redactor", e);
            pool = null;
        }
    }
}