From 219684bb52fef171e3b1f05fa7d5d08527c88137 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 01:08:55 +0900 Subject: [PATCH 01/12] chore(campaign): claim watch-derivation scaling and release timeout fixes From 1dab1e6c05b2a6bc688230a6db2276976a03a49a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 01:55:24 +0900 Subject: [PATCH 02/12] fix(unplugin): derive watch inputs once per envelope generation A graph-bearing transform envelope (typia >= 13.1.19) made every cached module delivery re-walk the whole reference graph to derive its watch inputs: O(modules x edges) pathIdentityKey computations, each costing real existsSync/realpathSync.native syscalls under the macOS branch, so real application builds never completed (the #970 residual). One envelope's derivation state now lives in a WeakMap keyed by the compiler result object: memoized path identities, lazily built reference-graph indexes (edges, spellings, candidates, globals, configs), lazily collected volatile/completeness identity sets, lazy first-match output/dependency key indexes, and a per-file watch-input memo. A host that never wires addWatchFile never pays the O(edges) index build. Derived watch lists are byte-identical to the previous derivation; freshness follows the generation contract since every derivable path is a recorded input whose change invalidates the generation and its state. Close #1007: fix(unplugin): per-delivery watch-input derivation rescans the whole reference graph, so graph-envelope builds never complete --- experimental/unplugin-perf/README.md | 7 +- experimental/unplugin-perf/src/index.ts | 275 ++++++++- packages/unplugin/src/core/transform.ts | 526 +++++++++++++----- ...unds_watch_derivation_probes_per_module.ts | 21 + .../src/internal/transform-project-cache.ts | 174 +++++- 5 files changed, 840 insertions(+), 163 deletions(-) create mode 100644 tests/test-unplugin/src/features/transform/test_transformttsc_bounds_watch_derivation_probes_per_module.ts diff --git a/experimental/unplugin-perf/README.md b/experimental/unplugin-perf/README.md index 9548f6b9c9..5bdd6060ef 100644 --- a/experimental/unplugin-perf/README.md +++ b/experimental/unplugin-perf/README.md @@ -3,12 +3,15 @@ This experiment drives the **real** `@ttsc/unplugin` Rollup plugin object over a synthetic project of `N` TypeScript files and measures, per simulated build: - **plugin runs** — how many times the whole project is re-transformed (native plugin spawns). A correct per-build cache transforms the project **once**. -- **`fs.readFileSync` calls / bytes** — the file-system work the adapter performs while serving the `N` modules. A correct cache walks the tree a constant number of times; the current code re-hashes the entire project on every module. +- **`fs.readFileSync` calls / bytes** — the file-system work the adapter performs while serving the `N` modules. A correct cache walks the tree a constant number of times per build. +- **fs identity probes** — the `existsSync`/`realpathSync.native` call volume the macOS `pathIdentityKey` branch would pay, counted under a simulated `process.platform = "darwin"` so it is observable on any host. Correct watch-input derivation pays it once per distinct graph path per generation, not once per module delivery. -The guarded invariant is **`plugin runs == 1`**: a build must transform the whole project exactly once and serve every other module from the per-build cache. The harness exits non-zero if any build exceeds one transform. +The guarded invariants are **`plugin runs == 1`** and, for the graph scenario, **bounded probes per module** — the harness exits non-zero when either breaks. - **Scenario A — output keys under the project root.** The cache hits, so the project is transformed once. (`reads` still grow with `N`: validating a cache hit re-hashes the project to detect a sibling-file change — bounded work that the existing invalidation contract requires.) - **Scenario B — one output key outside the validator's directory walk** (a `node_modules/**` path, exactly what the native host emits for program dependencies). Before the fix the store-time and validate-time hash key sets diverged, the cache _never_ hit, and the whole project was re-transformed once per module (`plugin runs == N`); now the cache hits and `plugin runs == 1`. +- **Scenario C — a graph-bearing envelope (the typia >= 13.1.19 shape).** The sidecar stamps `graph` (every module edges to its siblings plus `K` external `node_modules` declarations, and one missing resolution candidate) and per-file `dependencies`. Before the #1007 fix every cache-hit delivery re-walked the whole graph: probes per module grew linearly with the edge count (6.7k/12k/22.8k probes per module at E=2.6k/5.1k/10.1k, ~76-95 s for 99 deliveries), the O(modules x edges) syscall storm behind the #970 residual stall on macOS. The gate is 64 probes per module; the fixed code derives once per generation and stays flat regardless of `E`. +- **Scenario D — the same envelope without a build boundary** (the Vite development server's persistent-validation mode). Measurement only: it quantifies the deliberate per-delivery complete-input validation against a large external input set. The path is linear and is the #980 freshness contract, so no invariant gate applies. The adapter source is bundled on the fly with esbuild (with `ttsc` and `unplugin` kept external), so the production code path runs unmodified — no rebuilt `lib` required. diff --git a/experimental/unplugin-perf/src/index.ts b/experimental/unplugin-perf/src/index.ts index 89bb75c58a..85a6afcdb2 100644 --- a/experimental/unplugin-perf/src/index.ts +++ b/experimental/unplugin-perf/src/index.ts @@ -57,13 +57,49 @@ async function main(): Promise { ); } + console.log( + "\nScenario C — graph-bearing envelope (typia >= 13.1.19 shape):", + ); + console.log( + " invariant: plugin runs == 1 and macOS fs probes stay bounded per module;", + ); + console.log( + " per-delivery watch-input derivation must not re-walk the whole graph\n", + ); + for (const graphFanout of [25, 50, 100]) { + recordFailure( + failures, + await measureGraphBuild(adapter, { + count: 100, + emitExternalKey: false, + graphFanout, + }), + ); + } + + console.log( + "\nScenario D — graph envelope without a build boundary (Vite serve):", + ); + console.log( + " measurement only: per-module complete-validation cost with a large", + ); + console.log(" external input set (no invariant gate)\n"); + await measureServeValidation(adapter, { + count: 50, + emitExternalKey: false, + graphFanout: 100, + }); + if (failures.length !== 0) { console.error( - `\nFAIL: the per-build cache re-transformed the project more than once:\n ${failures.join("\n ")}`, + `\nFAIL: a scenario violated its invariant:\n ${failures.join("\n ")}`, ); process.exit(1); } - console.log("\nOK: every build ran exactly one whole-project transform."); + console.log( + "\nOK: every build ran exactly one whole-project transform and watch-input" + + " derivation stayed bounded per module.", + ); } function recordFailure(failures: string[], failure: string | undefined): void { @@ -123,6 +159,13 @@ function requireFromUnplugin(specifier: string): unknown { interface MeasureOptions { count: number; emitExternalKey: boolean; + /** + * Number of external `node_modules/dep{j}/index.d.ts` targets each module's + * graph edges and consulted-dependency list carry. Zero keeps the envelope + * graph-free (the typia 13.1.1 shape); a positive value stamps the + * graph-bearing shape typia >= 13.1.19 produces. + */ + graphFanout?: number; } async function measure( @@ -165,6 +208,163 @@ async function measure( : `scenario ${scenario} N=${options.count}: pluginRuns=${pluginRuns} (expected 1)`; } +/** + * An fs probe pair (`existsSync` + `realpathSync.native`) is what one + * `pathIdentityKey` call costs on macOS. A bounded watch-input derivation pays + * that once per distinct graph path per generation, so the amortized budget + * below is per module: well above the fixed point, far below the + * O(edges)-per-delivery defect this scenario reproduces. + */ +const GRAPH_PROBES_PER_MODULE_BUDGET = 64; + +/** + * Drive a build-scoped run over a graph-bearing envelope and count the fs + * probes a macOS host would pay for watch-input derivation. The first module + * delivery compiles (and therefore needs the real platform for the native + * spawn); the remaining deliveries are pure cache hits, so `process.platform` + * is flipped to `darwin` only for them, making the per-delivery probe volume of + * the macOS `pathIdentityKey` branch observable on any host. + */ +async function measureGraphBuild( + adapter: Adapter, + options: MeasureOptions, +): Promise { + const project = createProject(options); + const plugin = adapter.rollup({ + project: path.join(project, "tsconfig.json"), + }); + const runLog = path.join(project, ".plugin-runs"); + + await runBuild(plugin, project, runLog); + + const modules = projectModules(project); + const context = { + addWatchFile: () => undefined, + error: (message: unknown) => { + throw message instanceof Error ? message : new Error(String(message)); + }, + }; + fs.writeFileSync(runLog, ""); + process.env.PLUGIN_RUN_LOG = runLog; + await plugin.buildStart.call(context); + const [first, ...rest] = modules; + await plugin.transform.call(context, fs.readFileSync(first!, "utf8"), first!); + + const probes = instrumentFsProbes(); + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "darwin" }); + const started = process.hrtime.bigint(); + try { + for (const id of rest) { + await plugin.transform.call(context, fs.readFileSync(id, "utf8"), id); + } + } finally { + Object.defineProperty(process, "platform", platform); + probes.restore(); + } + const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6; + + const pluginRuns = fs.existsSync(runLog) + ? fs.readFileSync(runLog, "utf8").length + : 0; + const edges = options.count * (options.graphFanout ?? 0) + options.count - 1; + const probesPerModule = probes.calls / rest.length; + console.log( + ` N=${String(options.count).padStart(3)} ` + + `E=${String(edges).padStart(6)} ` + + `pluginRuns=${String(pluginRuns).padStart(3)} ` + + `probes=${String(probes.calls).padStart(9)} ` + + `probes/file=${probesPerModule.toFixed(1).padStart(9)} ` + + `${elapsedMs.toFixed(0).padStart(7)}ms`, + ); + + if (pluginRuns !== 1) { + return `scenario C N=${options.count} K=${options.graphFanout}: pluginRuns=${pluginRuns} (expected 1)`; + } + return probesPerModule <= GRAPH_PROBES_PER_MODULE_BUDGET + ? undefined + : `scenario C N=${options.count} K=${options.graphFanout}: probes/file=${probesPerModule.toFixed(1)} exceeds the bounded-derivation budget of ${GRAPH_PROBES_PER_MODULE_BUDGET} (per-delivery derivation re-walks the whole graph)`; +} + +/** + * Measure the serve-mode path: with no `buildStart` boundary the cache stays in + * persistent-validation mode, so every delivered module re-walks the project + * and re-reads the recorded external input set. Measurement only — the path is + * the deliberate #980 freshness contract; this quantifies how the graph + * envelope's external set multiplies it. + */ +async function measureServeValidation( + adapter: Adapter, + options: MeasureOptions, +): Promise { + const project = createProject(options); + const plugin = adapter.rollup({ + project: path.join(project, "tsconfig.json"), + }); + const runLog = path.join(project, ".plugin-runs"); + const context = { + addWatchFile: () => undefined, + error: (message: unknown) => { + throw message instanceof Error ? message : new Error(String(message)); + }, + }; + + // No buildStart anywhere: the cache never becomes build-scoped, which is + // exactly the state Vite's development server leaves it in. + process.env.PLUGIN_RUN_LOG = runLog; + const modules = projectModules(project); + for (const id of modules) { + await plugin.transform.call(context, fs.readFileSync(id, "utf8"), id); + } + + const counter = instrumentReadFileSync(); + const started = process.hrtime.bigint(); + for (const id of modules) { + await plugin.transform.call(context, fs.readFileSync(id, "utf8"), id); + } + const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6; + counter.restore(); + + console.log( + ` N=${String(options.count).padStart(3)} ` + + `externals=${String(options.graphFanout ?? 0).padStart(4)} ` + + `reads=${String(counter.calls).padStart(8)} ` + + `reads/file=${(counter.calls / options.count).toFixed(1).padStart(8)} ` + + `${elapsedMs.toFixed(0).padStart(7)}ms`, + ); +} + +/** + * Wrap the two fs calls the macOS `pathIdentityKey` branch pays per call + * (`existsSync` and `realpathSync.native`) with pass-through counters. + */ +function instrumentFsProbes(): { calls: number; restore: () => void } { + const counter = { calls: 0, restore: () => undefined }; + const originalExists = fs.existsSync; + const originalRealpath = fs.realpathSync.native; + (fs as { existsSync: typeof fs.existsSync }).existsSync = function ( + this: unknown, + ...args: Parameters + ) { + counter.calls += 1; + return originalExists.apply(this, args as never); + } as typeof fs.existsSync; + (fs.realpathSync as { native: typeof fs.realpathSync.native }).native = + function ( + this: unknown, + ...args: Parameters + ) { + counter.calls += 1; + return originalRealpath.apply(this, args as never); + } as typeof fs.realpathSync.native; + counter.restore = () => { + (fs as { existsSync: typeof fs.existsSync }).existsSync = originalExists; + (fs.realpathSync as { native: typeof fs.realpathSync.native }).native = + originalRealpath; + }; + return counter; +} + /** * Drive the real Rollup plugin like the bundler would: `buildStart` once, then * `transform` for every included module, in module order. @@ -280,8 +480,23 @@ function createProject(options: MeasureOptions): string { fs.mkdirSync(depDir, { recursive: true }); fs.writeFileSync(path.join(depDir, "index.d.ts"), "export {};\n", "utf8"); } + const graphFanout = options.graphFanout ?? 0; + if (graphFanout > 0) { + // The graph envelope's external targets must exist: the store-time + // snapshot hashes every recorded external input. + for (let index = 0; index < graphFanout; index += 1) { + const depDir = path.join(project, "node_modules", `dep${index}`); + fs.mkdirSync(depDir, { recursive: true }); + fs.writeFileSync( + path.join(depDir, "index.d.ts"), + `export declare const dep${index}: number;\n`, + "utf8", + ); + } + } // The Go sidecar keys its extra output entry only when asked. process.env.TTSC_PERF_EMIT_EXTERNAL = options.emitExternalKey ? "1" : "0"; + process.env.TTSC_PERF_GRAPH_FANOUT = String(graphFanout); return project; } @@ -310,11 +525,22 @@ function writeGoPlugin(project: string): void { ' "fmt"', ' "os"', ' "path/filepath"', + ' "sort"', + ' "strconv"', ' "strings"', ")", "", + "type referenceGraph struct {", + ' Edges map[string][]string `json:"edges"`', + ' Globals []string `json:"globals"`', + ' Configs []string `json:"configs"`', + ' Candidates map[string][]string `json:"candidates,omitempty"`', + "}", + "", "type transformResult struct {", - ' TypeScript map[string]string `json:"typescript"`', + ' TypeScript map[string]string `json:"typescript"`', + ' Dependencies map[string][]string `json:"dependencies,omitempty"`', + ' Graph *referenceGraph `json:"graph,omitempty"`', "}", "", "func main() { os.Exit(run(os.Args[1:])) }", @@ -353,17 +579,54 @@ function writeGoPlugin(project: string): void { ' srcDir := filepath.Join(root, "src")', " entries, err := os.ReadDir(srcDir)", " if err != nil { fmt.Fprintln(os.Stderr, err); return 2 }", + " names := []string{}", " for _, e := range entries {", ' if e.IsDir() || !strings.HasSuffix(e.Name(), ".ts") { continue }', - " data, err := os.ReadFile(filepath.Join(srcDir, e.Name()))", + " names = append(names, e.Name())", + " }", + " sort.Strings(names)", + " for _, name := range names {", + " data, err := os.ReadFile(filepath.Join(srcDir, name))", " if err != nil { fmt.Fprintln(os.Stderr, err); return 2 }", - ' ts["src/"+e.Name()] = string(data)', + ' ts["src/"+name] = string(data)', " }", ' if os.Getenv("TTSC_PERF_EMIT_EXTERNAL") == "1" {', ' ts["node_modules/dep/index.d.ts"] = "export {};\\n"', " }", "", - " data, _ := json.Marshal(transformResult{TypeScript: ts})", + " result := transformResult{TypeScript: ts}", + ' fanout, _ := strconv.Atoi(os.Getenv("TTSC_PERF_GRAPH_FANOUT"))', + " if fanout > 0 {", + " externals := make([]string, 0, fanout)", + " for j := 0; j < fanout; j++ {", + ' externals = append(externals, fmt.Sprintf("node_modules/dep%d/index.d.ts", j))', + " }", + " edges := map[string][]string{}", + " deps := map[string][]string{}", + " candidates := map[string][]string{}", + " for i, name := range names {", + ' key := "src/" + name', + " targets := []string{}", + " if i+1 < len(names) {", + ' targets = append(targets, "src/"+names[i+1])', + " }", + " targets = append(targets, externals...)", + " edges[key] = targets", + " deps[key] = externals", + " // A missing superseding probe, mirroring an unsuccessful", + " // module-resolution candidate the compiler records.", + ' candidates[key] = []string{fmt.Sprintf("node_modules/dep%d/index.ts", i%fanout)}', + " }", + " result.Dependencies = deps", + " result.Graph = &referenceGraph{", + " Edges: edges,", + " Globals: []string{},", + ' Configs: []string{"tsconfig.json"},', + " Candidates: candidates,", + " }", + " }", + "", + " data, _ := json.Marshal(result)", " fmt.Fprintln(os.Stdout, string(data))", " return 0", "}", diff --git a/packages/unplugin/src/core/transform.ts b/packages/unplugin/src/core/transform.ts index 6353adaa3a..db04c3e984 100644 --- a/packages/unplugin/src/core/transform.ts +++ b/packages/unplugin/src/core/transform.ts @@ -244,7 +244,7 @@ export async function transformTtsc( // A file the plugin declared volatile must never be served from the // cache: its output depends on non-file inputs, so the input-hash // snapshot cannot prove freshness. Fall through to a fresh transform. - !isVolatileFile({ + !isVolatileFile(envelopeDerivation(cached), { file, projectRoot: cached.projectRoot, result: cached.result, @@ -308,7 +308,9 @@ export async function transformTtsc( }); notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig }); markCachedSourceServed(cached, file); - if (isVolatileFile({ file, projectRoot, result })) { + if ( + isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result }) + ) { hooks?.markVolatile?.(); } return createTransformResult(source, code); @@ -377,6 +379,208 @@ function evictGeneration( } } +/** + * Per-envelope derivation state: every index the per-delivery paths need, built + * at most once and shared by all deliveries of one compiler result. + * + * Building the direct-edge index, the candidate entries, the declared-file + * identity sets, or the output/dependency key indexes per delivery costs + * O(envelope) `pathIdentityKey` computations per module — and each of those + * costs real `existsSync`/`realpathSync.native` syscalls on macOS + * ({@link pathIdentityKey}). A graph-bearing envelope (typia >= 13.1.19) turned + * that into O(modules x edges) syscalls per build, which is the + * samchon/ttsc#1007 stall. All deliveries of one generation share this state, + * so a delivery pays only its own reachability closure with memoized + * identities. + * + * Every index is lazy: a host that never wires `addWatchFile` and never misses + * a project-relative key pays nothing beyond the volatile/completeness + * membership sets, which are themselves built on first predicate use. + * + * Freshness matches the generation contract: every derivable path is a recorded + * project or external input, so persistent-mode validation already proves those + * paths unchanged on every non-build-scoped hit, and any change invalidates the + * generation — and this state with it. The `WeakMap` key is the envelope object + * itself, so the state dies when the generation does. + */ +interface TtscEnvelopeDerivation { + /** Memoized {@link pathIdentityKey} results, keyed by the exact input. */ + readonly identities: Map; + /** + * Lazily built reference-graph indexes, `undefined` until the first + * watch-input derivation. A host without an `addWatchFile` hook never pays + * the O(edges) build. + */ + graph?: TtscEnvelopeGraphIndexes; + /** + * Lazily collected identities of the envelope's `volatile` member files, + * `undefined` until the first volatility predicate. + */ + volatileFiles?: Set; + /** + * Lazily collected identities of the envelope's `dependenciesComplete` member + * files, `undefined` until the first completeness predicate. + */ + dependenciesComplete?: Set; + /** + * Lazily built identity -> output source index of the `typescript` map (first + * match wins, mirroring the historical scan). `undefined` until the first + * project-relative key miss. + */ + outputIndex?: Map; + /** + * Lazily built identity -> `dependencies` entries index (first match wins, + * mirroring the historical scan). `undefined` until the first key miss. + */ + dependencyIndex?: Map; + /** Per-file memo of the final derived watch-input list. */ + readonly watchInputs: Map; +} + +/** Reference-graph indexes shared by every watch-input derivation. */ +interface TtscEnvelopeGraphIndexes { + /** Identity of each direct-edge source -> its resolved absolute targets. */ + readonly edges: Map; + /** Identity of each direct-edge source -> its absolute spelling. */ + readonly spellings: Map; + /** Resolution-candidate entries in envelope order, sources pre-identified. */ + readonly candidates: { source: string; files: string[] }[]; + /** Resolved absolute `graph.globals` and `graph.configs` members. */ + readonly globals: string[]; + readonly configs: string[]; +} + +/** + * Derivation states keyed by the compiler result object. One result object is + * produced by one compile against one project root, so the root captured at + * build time is the only root the state ever sees. + */ +const ENVELOPE_DERIVATIONS = new WeakMap< + ITtscCompilerTransformation, + TtscEnvelopeDerivation +>(); + +/** Return the derivation state of `props.result`, building it on first use. */ +function envelopeDerivation(props: { + projectRoot: string; + result: ITtscCompilerTransformation; +}): TtscEnvelopeDerivation { + const existing = ENVELOPE_DERIVATIONS.get(props.result); + if (existing !== undefined) { + return existing; + } + const created: TtscEnvelopeDerivation = { + identities: new Map(), + watchInputs: new Map(), + }; + ENVELOPE_DERIVATIONS.set(props.result, created); + return created; +} + +/** + * Build the reference-graph indexes of one envelope on first watch-input + * derivation. Malformed sections are dropped member by member, mirroring the + * historical per-delivery scan. + */ +function envelopeGraphIndexes( + state: TtscEnvelopeDerivation, + props: { + projectRoot: string; + result: ITtscCompilerTransformation; + }, +): TtscEnvelopeGraphIndexes { + if (state.graph !== undefined) { + return state.graph; + } + const built: TtscEnvelopeGraphIndexes = { + edges: new Map(), + spellings: new Map(), + candidates: [], + globals: [], + configs: [], + }; + const graph = + props.result.type === "exception" ? undefined : props.result.graph; + if (graph !== undefined) { + for (const [source, targets] of Object.entries(graph.edges ?? {})) { + if (!Array.isArray(targets)) { + continue; + } + const absolute = path.resolve(props.projectRoot, source); + const identity = derivationIdentity(state, absolute); + built.spellings.set(identity, absolute); + const entries = built.edges.get(identity) ?? []; + entries.push( + ...targets + .filter( + (target): target is string => + typeof target === "string" && target.length !== 0, + ) + .map((target) => path.resolve(props.projectRoot, target)), + ); + built.edges.set(identity, entries); + } + built.globals.push(...selectListedFiles(props.projectRoot, graph.globals)); + built.configs.push(...selectListedFiles(props.projectRoot, graph.configs)); + for (const [source, candidates] of Object.entries(graph.candidates ?? {})) { + if (!Array.isArray(candidates)) { + continue; + } + built.candidates.push({ + source: derivationIdentity( + state, + path.resolve(props.projectRoot, source), + ), + files: selectListedFiles(props.projectRoot, candidates), + }); + } + } + state.graph = built; + return built; +} + +/** + * {@link pathIdentityKey} memoized inside one envelope's derivation state. + * Callers always pass already-resolved absolute paths, so the input string is a + * stable memo key. + */ +function derivationIdentity( + state: TtscEnvelopeDerivation, + file: string, +): string { + const existing = state.identities.get(file); + if (existing !== undefined) { + return existing; + } + const identity = pathIdentityKey(file); + state.identities.set(file, identity); + return identity; +} + +/** + * Fold one envelope member list (`volatile`, `dependenciesComplete`) into an + * identity set. Members are keyed like `typescript`, so a project-relative and + * an absolute spelling of the same file share one identity; a malformed member + * is ignored rather than fatal. + */ +function collectDeclaredIdentities( + state: TtscEnvelopeDerivation, + projectRoot: string, + listed: unknown, +): Set { + const output = new Set(); + if (!Array.isArray(listed)) { + return output; + } + for (const entry of listed) { + if (typeof entry !== "string" || entry.length === 0) { + continue; + } + output.add(derivationIdentity(state, path.resolve(projectRoot, entry))); + } + return output; +} + /** * Forward every derived watch input for `file` to the adapter's `addWatchFile` * hook: the plugin-reported `dependencies[file]` list unioned with the @@ -425,7 +629,10 @@ function notifyWatchInputs( * volatile keeps the baseline: the two declarations contradict, so the * conservative one wins. * - * Returns an empty list on exceptions. + * The derived list is a pure function of the envelope and the file's filesystem + * identity, so it is computed at most once per generation per file: sibling and + * repeated deliveries replay the per-envelope memo ({@link envelopeDerivation}) + * instead of re-walking the graph. Returns an empty list on exceptions. */ function selectWatchInputs(props: { file: string; @@ -433,22 +640,49 @@ function selectWatchInputs(props: { result: ITtscCompilerTransformation; temporaryTsconfig?: string; }): string[] { + if (props.result.type === "exception") { + return []; + } + const state = envelopeDerivation(props); + const fileIdentity = derivationIdentity(state, props.file); + const memoized = state.watchInputs.get(fileIdentity); + if (memoized !== undefined) { + return memoized; + } + const derived = deriveWatchInputs(state, props, fileIdentity); + state.watchInputs.set(fileIdentity, derived); + return derived; +} + +/** Compute one file's watch-input list over the shared per-envelope state. */ +function deriveWatchInputs( + state: TtscEnvelopeDerivation, + props: { + file: string; + projectRoot: string; + result: ITtscCompilerTransformation; + temporaryTsconfig?: string; + }, + fileIdentity: string, +): string[] { + const graph = envelopeGraphIndexes(state, props); const output: string[] = []; const seen = new Set(); - const excluded = new Set( - props.temporaryTsconfig === undefined - ? [pathIdentityKey(props.file)] - : [pathIdentityKey(props.file), pathIdentityKey(props.temporaryTsconfig)], - ); + const excluded = new Set([fileIdentity]); + if (props.temporaryTsconfig !== undefined) { + excluded.add(derivationIdentity(state, props.temporaryTsconfig)); + } for (const absolute of [ ...selectFileDependencies(props), - ...selectGraphInputs({ + ...selectGraphInputs(graph, state, { ...props, - complete: declaresCompleteDependencies(props) && !isVolatileFile(props), + complete: + declaresCompleteDependencies(state, props) && + !isVolatileFile(state, props), }), - ...selectResolutionCandidateInputs(props), + ...selectResolutionCandidateInputs(graph, state, props), ]) { - const identity = pathIdentityKey(absolute); + const identity = derivationIdentity(state, absolute); if (excluded.has(identity) || seen.has(identity)) { continue; } @@ -463,35 +697,37 @@ function selectWatchInputs(props: { * module reachable from `file`. They remain host-owned even when a plugin * declares `dependenciesComplete`: plugin code cannot vouch for a compiler * resolution change that occurs without any plugin input changing. + * + * Candidate entries and their source identities come from the shared + * per-envelope state, so one delivery scans only the candidates themselves + * instead of re-resolving every candidate source. */ -function selectResolutionCandidateInputs(props: { - file: string; - projectRoot: string; - result: ITtscCompilerTransformation; -}): string[] { - if (props.result.type === "exception") { - return []; - } - const graph = props.result.graph; - if (graph === undefined || graph.candidates === undefined) { +function selectResolutionCandidateInputs( + graph: TtscEnvelopeGraphIndexes, + state: TtscEnvelopeDerivation, + props: { + file: string; + projectRoot: string; + result: ITtscCompilerTransformation; + }, +): string[] { + if ( + props.result.type === "exception" || + props.result.graph?.candidates === undefined + ) { return []; } const reachable = new Set( - selectReachableSources(props.projectRoot, props.file, graph).map( - pathIdentityKey, + selectReachableSources(graph, state, props.file).map((source) => + derivationIdentity(state, source), ), ); const output: string[] = []; - for (const [source, candidates] of Object.entries(graph.candidates)) { - if ( - !reachable.has( - pathIdentityKey(path.resolve(props.projectRoot, source)), - ) || - !Array.isArray(candidates) - ) { + for (const entry of graph.candidates) { + if (!reachable.has(entry.source)) { continue; } - output.push(...selectListedFiles(props.projectRoot, candidates)); + output.push(...entry.files); } return output; } @@ -510,62 +746,47 @@ function selectResolutionCandidateInputs(props: { * `dependencies[file]` list the complete replacement for them. Returns an empty * list on exceptions or without a graph. */ -function selectGraphInputs(props: { - complete: boolean; - file: string; - projectRoot: string; - result: ITtscCompilerTransformation; -}): string[] { - if (props.result.type === "exception") { - return []; - } - const graph = props.result.graph; - if (graph === undefined) { +function selectGraphInputs( + graph: TtscEnvelopeGraphIndexes, + state: TtscEnvelopeDerivation, + props: { + complete: boolean; + file: string; + projectRoot: string; + result: ITtscCompilerTransformation; + }, +): string[] { + if (props.result.type === "exception" || props.result.graph === undefined) { return []; } const output: string[] = []; if (!props.complete) { - output.push(...selectReachableEdges(props.projectRoot, props.file, graph)); - output.push(...selectListedFiles(props.projectRoot, graph.globals)); + output.push(...selectReachableEdges(graph, state, props.file)); + output.push(...graph.globals); } - output.push(...selectListedFiles(props.projectRoot, graph.configs)); + output.push(...graph.configs); return output; } /** * Walk the reachability closure of the graph's direct `edges` from `file`, * returning the absolute path of every file reached (the starting file itself - * excluded, even when a cycle points back at it). + * excluded, even when a cycle points back at it). Reads the shared per-envelope + * edge index instead of rebuilding it per delivery. */ function selectReachableEdges( - projectRoot: string, + graph: TtscEnvelopeGraphIndexes, + state: TtscEnvelopeDerivation, file: string, - graph: ITtscCompilerTransformation.IReferenceGraph, ): string[] { - const edges = new Map(); - for (const [source, targets] of Object.entries(graph.edges ?? {})) { - if (!Array.isArray(targets)) { - continue; - } - const identity = pathIdentityKey(path.resolve(projectRoot, source)); - const entries = edges.get(identity) ?? []; - entries.push( - ...targets - .filter( - (target): target is string => - typeof target === "string" && target.length !== 0, - ) - .map((target) => path.resolve(projectRoot, target)), - ); - edges.set(identity, entries); - } const output: string[] = []; - const visited = new Set([pathIdentityKey(file)]); + const visited = new Set([derivationIdentity(state, file)]); const queue = [file]; while (queue.length !== 0) { const current = queue.pop()!; - for (const target of edges.get(pathIdentityKey(current)) ?? []) { - const identity = pathIdentityKey(target); + for (const target of graph.edges.get(derivationIdentity(state, current)) ?? + []) { + const identity = derivationIdentity(state, target); if (visited.has(identity)) { continue; } @@ -583,43 +804,24 @@ function selectReachableEdges( * than targets, so this is intentionally distinct from selectReachableEdges. */ function selectReachableSources( - projectRoot: string, + graph: TtscEnvelopeGraphIndexes, + state: TtscEnvelopeDerivation, file: string, - graph: ITtscCompilerTransformation.IReferenceGraph, ): string[] { - const edges = new Map(); - const spellings = new Map(); - for (const [source, targets] of Object.entries(graph.edges ?? {})) { - if (!Array.isArray(targets)) { - continue; - } - const absolute = path.resolve(projectRoot, source); - const identity = pathIdentityKey(absolute); - spellings.set(identity, absolute); - const entries = edges.get(identity) ?? []; - entries.push( - ...targets - .filter( - (target): target is string => - typeof target === "string" && target.length !== 0, - ) - .map((target) => path.resolve(projectRoot, target)), - ); - edges.set(identity, entries); - } const output = [file]; - const visited = new Set([pathIdentityKey(file)]); + const visited = new Set([derivationIdentity(state, file)]); const queue = [file]; while (queue.length !== 0) { const current = queue.pop()!; - for (const target of edges.get(pathIdentityKey(current)) ?? []) { - const identity = pathIdentityKey(target); + for (const target of graph.edges.get(derivationIdentity(state, current)) ?? + []) { + const identity = derivationIdentity(state, target); if (visited.has(identity)) { continue; } visited.add(identity); queue.push(target); - output.push(spellings.get(identity) ?? target); + output.push(graph.spellings.get(identity) ?? target); } } return output; @@ -647,17 +849,26 @@ function selectListedFiles(projectRoot: string, listed: unknown): string[] { /** * Report whether the plugin declared `file` volatile: its output depends on * non-file inputs (environment, time, network), so neither the project - * transform cache nor a bundler's persistent cache may replay it. + * transform cache nor a bundler's persistent cache may replay it. Reads the + * per-envelope identity set instead of rescanning the member list. */ -function isVolatileFile(props: { - file: string; - projectRoot: string; - result: ITtscCompilerTransformation; -}): boolean { +function isVolatileFile( + state: TtscEnvelopeDerivation, + props: { + file: string; + projectRoot: string; + result: ITtscCompilerTransformation; + }, +): boolean { if (props.result.type === "exception") { return false; } - return declaresFile(props.result.volatile, props); + const declared = (state.volatileFiles ??= collectDeclaredIdentities( + state, + props.projectRoot, + props.result.volatile, + )); + return declared.has(derivationIdentity(state, props.file)); } /** @@ -666,44 +877,30 @@ function isVolatileFile(props: { * itself and the universal config chain. Callers must still keep the baseline * for a file the same envelope declared volatile. */ -function declaresCompleteDependencies(props: { - file: string; - projectRoot: string; - result: ITtscCompilerTransformation; -}): boolean { - if (props.result.type === "exception") { - return false; - } - return declaresFile(props.result.dependenciesComplete, props); -} - -/** - * Report whether one of the envelope's transformed-file lists (`volatile`, - * `dependenciesComplete`) names `file`. Members are keyed like `typescript`, so - * a project-relative and an absolute spelling of the same file both match; a - * malformed member is ignored rather than fatal. - */ -function declaresFile( - listed: unknown, - props: { file: string; projectRoot: string }, +function declaresCompleteDependencies( + state: TtscEnvelopeDerivation, + props: { + file: string; + projectRoot: string; + result: ITtscCompilerTransformation; + }, ): boolean { - if (!Array.isArray(listed)) { + if (props.result.type === "exception") { return false; } - return listed.some( - (entry) => - typeof entry === "string" && - entry.length !== 0 && - pathIdentityKey(path.resolve(props.projectRoot, entry)) === - pathIdentityKey(props.file), - ); + const declared = (state.dependenciesComplete ??= collectDeclaredIdentities( + state, + props.projectRoot, + props.result.dependenciesComplete, + )); + return declared.has(derivationIdentity(state, props.file)); } /** * Extract the absolute, deduplicated dependency list for a single file from the * compiler result. Mirrors {@link selectTransformedSource}'s key lookup: fast - * project-relative match first, then a resolve-based scan. Returns an empty - * list on exceptions or when the plugin reported nothing. + * project-relative match first, then a per-envelope identity index. Returns an + * empty list on exceptions or when the plugin reported nothing. */ function selectFileDependencies(props: { file: string; @@ -719,29 +916,30 @@ function selectFileDependencies(props: { } const key = toProjectKey(props.projectRoot, props.file); let entries = dependencies[key]; + const state = envelopeDerivation(props); if (entries === undefined) { - for (const [candidate, candidateEntries] of Object.entries(dependencies)) { - if ( - pathIdentityKey(path.resolve(props.projectRoot, candidate)) === - pathIdentityKey(props.file) - ) { - entries = candidateEntries; - break; - } - } + const index = (state.dependencyIndex ??= createEnvelopeKeyIndex( + state, + props.projectRoot, + dependencies, + )); + entries = index.get(derivationIdentity(state, props.file)) as + | string[] + | undefined; } if (!Array.isArray(entries)) { return []; } const output: string[] = []; const seen = new Set(); + const fileIdentity = derivationIdentity(state, props.file); for (const entry of entries) { if (typeof entry !== "string" || entry.length === 0) { continue; } const absolute = path.resolve(props.projectRoot, entry); - const identity = pathIdentityKey(absolute); - if (identity === pathIdentityKey(props.file) || seen.has(identity)) { + const identity = derivationIdentity(state, absolute); + if (identity === fileIdentity || seen.has(identity)) { continue; } seen.add(identity); @@ -750,6 +948,29 @@ function selectFileDependencies(props: { return output; } +/** + * Build a first-match identity index over one envelope key map (`typescript`, + * `dependencies`), mirroring the historical per-delivery scan that returned the + * first entry whose resolved key matched by filesystem identity. + */ +function createEnvelopeKeyIndex( + state: TtscEnvelopeDerivation, + projectRoot: string, + keyed: Record, +): Map { + const index = new Map(); + for (const [candidate, value] of Object.entries(keyed)) { + const identity = derivationIdentity( + state, + path.resolve(projectRoot, candidate), + ); + if (!index.has(identity)) { + index.set(identity, value); + } + } + return index; +} + /** * Strip a query string or hash fragment from a bundler module id. * @@ -1479,14 +1700,17 @@ function selectTransformedSource(props: { if (direct !== undefined) { return direct; } - // Slow path: resolve each candidate to an absolute path for comparison. - for (const [candidate, source] of Object.entries(props.result.typescript)) { - if ( - pathIdentityKey(path.resolve(props.projectRoot, candidate)) === - pathIdentityKey(props.file) - ) { - return source; - } + // Slow path: the first-match identity index of the envelope's `typescript` + // keys, built once per generation instead of scanned per delivery. + const state = envelopeDerivation(props); + const index = (state.outputIndex ??= createEnvelopeKeyIndex( + state, + props.projectRoot, + props.result.typescript, + )); + const source = index.get(derivationIdentity(state, props.file)); + if (source !== undefined) { + return source; } throw new Error(`ttsc transform did not return output for ${props.file}`); } diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_bounds_watch_derivation_probes_per_module.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_bounds_watch_derivation_probes_per_module.ts new file mode 100644 index 0000000000..270fbd643a --- /dev/null +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_bounds_watch_derivation_probes_per_module.ts @@ -0,0 +1,21 @@ +import { assertSiblingDeliveriesDoNotReprobeGraph } from "../../internal/transform-project-cache"; + +/** + * Verifies cached sibling deliveries derive watch inputs without re-probing the + * filesystem per module. + * + * Pins samchon/ttsc#1007: a graph-bearing envelope (typia >= 13.1.19) made + * every cache-hit delivery re-walk the whole reference graph with real + * `existsSync`/`realpathSync.native` calls under the macOS `pathIdentityKey` + * branch, scaling O(modules x edges) into the #970 residual build stall. The + * simulated-darwin probe counter makes that branch's syscall volume observable + * on any CI host. + * + * 1. Compile a six-module project whose envelope carries a ~150-edge graph. + * 2. Deliver every sibling from the cache under a probe counter. + * 3. Assert identical watch lists and a bounded probe count per delivery. + */ +export const test_transformttsc_bounds_watch_derivation_probes_per_module = + async () => { + await assertSiblingDeliveriesDoNotReprobeGraph(); + }; diff --git a/tests/test-unplugin/src/internal/transform-project-cache.ts b/tests/test-unplugin/src/internal/transform-project-cache.ts index 9735c85ed6..853ab6edba 100644 --- a/tests/test-unplugin/src/internal/transform-project-cache.ts +++ b/tests/test-unplugin/src/internal/transform-project-cache.ts @@ -15,11 +15,15 @@ import path from "node:path"; * `emitExternalKey` makes the fixture transform emit one output entry keyed * outside the project's directory walk (a `node_modules/**` path), reproducing * what the native host does for program dependencies (`node_modules` - * declarations, sibling-package sources). + * declarations, sibling-package sources). `graphFanout` makes the fixture stamp + * a reference-graph envelope where every module edges to every sibling plus + * that many planted `node_modules/dep{j}/index.d.ts` declarations — the shape + * typia >= 13.1.19 produces. */ interface ICacheProjectOptions { emitExternalKey?: boolean; fileCount?: number; + graphFanout?: number; } // Build the Go fixture once per process; transformTtsc shells out to it. @@ -337,6 +341,116 @@ async function assertFirstModuleDeliveriesDoNotRehashProject(): Promise { assert.equal(fs.readFileSync(project.runLog, "utf8").length, 1); } +/** + * Asserts samchon/ttsc#1007: cache-hit sibling deliveries of one graph-bearing + * generation do not re-probe the filesystem per module. + * + * Watch-input derivation must pay the graph's identity computations once per + * generation; after that a delivery costs only its own memoized lookups. The + * probe counter simulates the macOS `pathIdentityKey` branch (one `existsSync` + * + one `realpathSync.native` per call) on any host, so the bound holds + * identically across CI platforms: before the fix every delivery re-walked the + * whole edge set with two syscalls per path, which scaled O(modules x edges) + * into the #970 residual stall. + */ +async function assertSiblingDeliveriesDoNotReprobeGraph(): Promise { + const { + beginTtscTransformBuild, + createTtscTransformCache, + resolveOptions, + transformTtsc, + } = await TestUnpluginRuntime.loadUnpluginApi(); + const graphFanout = 24; + const project = createCacheProject({ fileCount: 6, graphFanout }); + const modules = projectModules(project.root); + const cache = createTtscTransformCache(); + beginTtscTransformBuild(cache); + const options = resolveOptions(); + + // The first delivery compiles, so it needs the real platform for the + // native spawn; the remaining deliveries are pure cache hits. + const watched = new Map(); + const deliver = (file: string) => + transformTtsc( + file, + fs.readFileSync(file, "utf8"), + options, + undefined, + cache, + { + addWatchFile: (input: string) => { + const list = watched.get(file) ?? []; + list.push(input); + watched.set(file, list); + }, + }, + ); + await deliver(modules[0]!); + + const probes = countFsIdentityProbes(); + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + for (const file of modules.slice(1)) { + await deliver(file); + } + } finally { + Object.defineProperty(process, "platform", platform); + probes.restore(); + } + + // Derivation parity: each module registers its own reach union, minus + // itself, plus the universal config chain. + const expected = (file: string) => + [ + ...modules.filter((other) => other !== file), + ...Array.from({ length: graphFanout }, (_, index) => + path.join(project.root, "node_modules", `dep${index}`, "index.d.ts"), + ), + path.join(project.root, "tsconfig.json"), + ].sort(); + for (const file of modules) { + assert.deepEqual([...(watched.get(file) ?? [])].sort(), expected(file)); + } + + const perDelivery = probes.calls / (modules.length - 1); + assert.ok( + perDelivery <= 24, + `watch derivation re-probed the filesystem ${perDelivery.toFixed(1)} times per delivery (bound: 24)`, + ); +} + +/** + * Wrap the two fs calls the macOS `pathIdentityKey` branch pays per call + * (`existsSync` and `realpathSync.native`) with pass-through counters. + */ +function countFsIdentityProbes(): { calls: number; restore: () => void } { + const counter = { calls: 0, restore: () => undefined }; + const originalExists = fs.existsSync; + const originalRealpath = fs.realpathSync.native; + (fs as { existsSync: typeof fs.existsSync }).existsSync = function ( + this: unknown, + ...args: Parameters + ) { + counter.calls += 1; + return originalExists.apply(this, args as never); + } as typeof fs.existsSync; + (fs.realpathSync as { native: typeof fs.realpathSync.native }).native = + function ( + this: unknown, + ...args: Parameters + ) { + counter.calls += 1; + return originalRealpath.apply(this, args as never); + } as typeof fs.realpathSync.native; + counter.restore = () => { + (fs as { existsSync: typeof fs.existsSync }).existsSync = originalExists; + (fs.realpathSync as { native: typeof fs.realpathSync.native }).native = + originalRealpath; + }; + return counter; +} + /** * Asserts a cache with no build-start lifecycle validates every generation hit, * including a module that generation has not served before. @@ -520,6 +634,7 @@ function createCacheProject(options: ICacheProjectOptions): { name: "cache-probe", runLog, emitExternal: options.emitExternalKey === true, + graphFanout: options.graphFanout ?? 0, }, ], }, @@ -550,6 +665,18 @@ function createCacheProject(options: ICacheProjectOptions): { fs.mkdirSync(depDir, { recursive: true }); fs.writeFileSync(path.join(depDir, "index.d.ts"), "export {};\n", "utf8"); } + const graphFanout = options.graphFanout ?? 0; + for (let index = 0; index < graphFanout; index += 1) { + // The graph envelope's external targets must exist: the store-time + // snapshot hashes every recorded external input. + const depDir = path.join(root, "node_modules", `dep${index}`); + fs.mkdirSync(depDir, { recursive: true }); + fs.writeFileSync( + path.join(depDir, "index.d.ts"), + `export declare const dep${index}: number;\n`, + "utf8", + ); + } writeGoPlugin(root); return { root, runLog }; } @@ -588,8 +715,15 @@ function writeGoPlugin(root: string): void { ' Config map[string]any `json:"config"`', "}", "", + "type graphSection struct {", + ' Edges map[string][]string `json:"edges"`', + ' Globals []string `json:"globals"`', + ' Configs []string `json:"configs"`', + "}", + "", "type transformResult struct {", ' TypeScript map[string]string `json:"typescript"`', + ' Graph *graphSection `json:"graph,omitempty"`', "}", "", "func main() { os.Exit(run(os.Args[1:])) }", @@ -629,17 +763,43 @@ function writeGoPlugin(root: string): void { ' srcDir := filepath.Join(root, "src")', " entries, err := os.ReadDir(srcDir)", " if err != nil { fmt.Fprintln(os.Stderr, err); return 2 }", + " names := []string{}", " for _, e := range entries {", ' if e.IsDir() || !strings.HasSuffix(e.Name(), ".ts") { continue }', - " data, err := os.ReadFile(filepath.Join(srcDir, e.Name()))", + " names = append(names, e.Name())", + " }", + " for _, name := range names {", + " data, err := os.ReadFile(filepath.Join(srcDir, name))", " if err != nil { fmt.Fprintln(os.Stderr, err); return 2 }", - ' ts["src/"+e.Name()] = strings.ReplaceAll(string(data), "PROBE", "PROBED")', + ' ts["src/"+name] = strings.ReplaceAll(string(data), "PROBE", "PROBED")', " }", ' if boolValue(cfg, "emitExternal") {', ' ts["node_modules/dep/index.d.ts"] = "export {};\\n"', " }", "", - " data, _ := json.Marshal(transformResult{TypeScript: ts})", + " result := transformResult{TypeScript: ts}", + ' if fanout := int(numberValue(cfg, "graphFanout")); fanout > 0 {', + " externals := make([]string, 0, fanout)", + " for j := 0; j < fanout; j++ {", + ' externals = append(externals, fmt.Sprintf("node_modules/dep%d/index.d.ts", j))', + " }", + " edges := map[string][]string{}", + " for _, name := range names {", + " targets := []string{}", + " for _, other := range names {", + ' if other != name { targets = append(targets, "src/"+other) }', + " }", + " targets = append(targets, externals...)", + ' edges["src/"+name] = targets', + " }", + " result.Graph = &graphSection{", + " Edges: edges,", + " Globals: []string{},", + ' Configs: []string{"tsconfig.json"},', + " }", + " }", + "", + " data, _ := json.Marshal(result)", " fmt.Fprintln(os.Stdout, string(data))", " return 0", "}", @@ -662,6 +822,11 @@ function writeGoPlugin(root: string): void { " return value", "}", "", + "func numberValue(config map[string]any, key string) float64 {", + " value, _ := config[key].(float64)", + " return value", + "}", + "", ].join("\n"), "utf8", ); @@ -675,6 +840,7 @@ export { assertHostExceptionTransformIsEvictedAndRecovers, assertPersistentCacheValidatesAnUnservedModule, assertRejectedTransformIsEvictedAndRecovers, + assertSiblingDeliveriesDoNotReprobeGraph, assertStaleEvictionKeepsNewerGeneration, assertStaleMismatchUsesNewerGeneration, assertSupersededMatchingGenerationIsNotServed, From 4915edf6bf509e5ac36a1b19f945faaa38c0fd81 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 01:55:37 +0900 Subject: [PATCH 03/12] ci(release): fail fast when a publish step stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every release.yml job and step ran with GitHub's 360-minute default timeout, so a stalled Marketplace or npm publish would hold the tag's release for hours while looking identical to the healthy ~21-minute build phase — the same ambiguity this pipeline already produced once at the Marketplace boundary (d90544084) and again when the v0.22.0 run only appeared frozen. Bound the publish steps at 10 (Marketplace, ~0.5 minute healthy) and 20 (npm, ~5.5 minutes healthy) minutes and every job at 15-60 minutes so a genuine stall fails fast with a clear signal. Close #1008: ci(release): publish steps have no fail-fast timeout, so a stalled publish holds the release silently for hours --- .github/workflows/release.yml | 14 ++++++ ...elease_workflow_bounds_publish_timeouts.ts | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28793dcde2..3f464c19c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,8 @@ permissions: jobs: publish: runs-on: ubuntu-latest + # One full build-plus-publish cycle takes ~35 minutes end to end. + timeout-minutes: 60 steps: # `pnpm run build` cross-compiles every platform package in one job: seven # GOOS/GOARCH pairs, each producing a tsgo-sized ttsc and ttscserver, each @@ -78,13 +80,23 @@ jobs: ! printf '%s\n%s\n' "$ttsc_out" "$server_out" | grep -q '0.0.0-dev' ! printf '%s\n%s\n' "$ttsc_out" "$server_out" | grep -q 'commit dev' - name: Publish VS Code Marketplace extension + # Fail fast on a stalled Marketplace call: the healthy step takes + # under a minute (11-28 s observed), and the default 360-minute job + # timeout would otherwise hold the npm publish hostage for hours + # while looking like a slow build (#1008). + timeout-minutes: 10 run: SKIP_BUILD=1 bash scripts/publish-vscode-marketplace.sh env: VSCE_PAT: ${{ secrets.VSCE_PAT }} - name: Publish to npm + # Same fail-fast contract as the Marketplace step: the healthy step + # takes ~5.5 minutes; the default 360-minute job timeout would hold a + # stalled publish silently for hours (#1008). + timeout-minutes: 20 run: pnpm run package:latest:publish wasm-smoke: runs-on: ubuntu-latest + timeout-minutes: 15 needs: publish steps: - uses: actions/setup-node@v6 @@ -102,6 +114,7 @@ jobs: node -e "if(!require('@ttsc/wasm'))process.exit(1)" vscode-smoke: runs-on: ubuntu-latest + timeout-minutes: 15 needs: publish steps: - uses: actions/checkout@v4 @@ -146,6 +159,7 @@ jobs: NODE release: runs-on: ubuntu-latest + timeout-minutes: 15 needs: [wasm-smoke, vscode-smoke] permissions: contents: write diff --git a/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts b/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts new file mode 100644 index 0000000000..3359591f07 --- /dev/null +++ b/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts @@ -0,0 +1,50 @@ +import { assert, fs, path, workspaceRoot } from "../../internal/toolchain"; + +/** + * Verifies every release job and publish step carries an explicit timeout. + * + * Locks samchon/ttsc#1008: the release pipeline once deadlocked at the + * Marketplace boundary (d90544084), and without `timeout-minutes` a stalled + * publish would hold the tag's release for GitHub's 360-minute job default + * while looking identical to the healthy ~21-minute build phase. The exact + * budgets are pinned so a future edit cannot silently relax them back into a + * multi-hour silent hold. + * + * 1. Read the release workflow and drop its comment lines. + * 2. Slice the two publish steps and every job block out of the workflow. + * 3. Assert each publish step's exact budget and one job-level timeout each. + */ +export const test_release_workflow_bounds_publish_timeouts = () => { + const source = fs.readFileSync( + path.join(workspaceRoot, ".github", "workflows", "release.yml"), + "utf8", + ); + // Timeout keys live on uncommented lines by construction; dropping comments + // keeps prose about timeouts from counting as the key itself. + const workflow = source + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); + + for (const [step, budget] of [ + ["Publish VS Code Marketplace extension", "timeout-minutes: 10"], + ["Publish to npm", "timeout-minutes: 20"], + ] as Array<[string, string]>) { + const start = workflow.indexOf(`- name: ${step}`); + assert.notEqual(start, -1, `expected to find the ${step} step`); + const end = workflow.indexOf("- name:", start + 1); + const block = workflow.slice(start, end === -1 ? undefined : end); + assert.ok( + block.includes(budget), + `the ${step} step must declare ${budget}, got:\n${block}`, + ); + } + + const jobLevelTimeouts = + workflow.match(/^ {4}timeout-minutes: \d+\r?$/gm) ?? []; + assert.equal( + jobLevelTimeouts.length, + 4, + `every release job must declare one job-level timeout-minutes, found: ${jobLevelTimeouts.join(", ") || "none"}`, + ); +}; From 201f66ddf25eeb5fa758d6855b049f05c4f3f798 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 10:16:06 +0900 Subject: [PATCH 04/12] test(release): bind timeout assertions to yaml owners Validate exact job and publish-step timeout ownership so moved, relaxed, or newly unbounded workflow nodes cannot satisfy the regression test accidentally. Also repair the unplugin probe-test formatting and align the performance scenario prose with its actual next-sibling graph fixture. --- experimental/unplugin-perf/README.md | 2 +- ...elease_workflow_bounds_publish_timeouts.ts | 84 ++++++++++++++----- .../src/internal/transform-project-cache.ts | 10 +-- 3 files changed, 67 insertions(+), 29 deletions(-) diff --git a/experimental/unplugin-perf/README.md b/experimental/unplugin-perf/README.md index 5bdd6060ef..bf2f8e397b 100644 --- a/experimental/unplugin-perf/README.md +++ b/experimental/unplugin-perf/README.md @@ -10,7 +10,7 @@ The guarded invariants are **`plugin runs == 1`** and, for the graph scenario, * - **Scenario A — output keys under the project root.** The cache hits, so the project is transformed once. (`reads` still grow with `N`: validating a cache hit re-hashes the project to detect a sibling-file change — bounded work that the existing invalidation contract requires.) - **Scenario B — one output key outside the validator's directory walk** (a `node_modules/**` path, exactly what the native host emits for program dependencies). Before the fix the store-time and validate-time hash key sets diverged, the cache _never_ hit, and the whole project was re-transformed once per module (`plugin runs == N`); now the cache hits and `plugin runs == 1`. -- **Scenario C — a graph-bearing envelope (the typia >= 13.1.19 shape).** The sidecar stamps `graph` (every module edges to its siblings plus `K` external `node_modules` declarations, and one missing resolution candidate) and per-file `dependencies`. Before the #1007 fix every cache-hit delivery re-walked the whole graph: probes per module grew linearly with the edge count (6.7k/12k/22.8k probes per module at E=2.6k/5.1k/10.1k, ~76-95 s for 99 deliveries), the O(modules x edges) syscall storm behind the #970 residual stall on macOS. The gate is 64 probes per module; the fixed code derives once per generation and stays flat regardless of `E`. +- **Scenario C — a graph-bearing envelope (the typia >= 13.1.19 shape).** The sidecar stamps `graph` (every module edges to its next sibling plus `K` external `node_modules` declarations, and one missing resolution candidate) and per-file `dependencies`. Before the #1007 fix every cache-hit delivery re-walked the whole graph: probes per module grew linearly with the edge count (6.7k/12k/22.8k probes per module at E=2.6k/5.1k/10.1k, ~76-95 s for 99 deliveries), the O(modules x edges) syscall storm behind the #970 residual stall on macOS. The gate is 64 probes per module; the fixed code derives once per generation and stays flat regardless of `E`. - **Scenario D — the same envelope without a build boundary** (the Vite development server's persistent-validation mode). Measurement only: it quantifies the deliberate per-delivery complete-input validation against a large external input set. The path is linear and is the #980 freshness contract, so no invariant gate applies. The adapter source is bundled on the fly with esbuild (with `ttsc` and `unplugin` kept external), so the production code path runs unmodified — no rebuilt `lib` required. diff --git a/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts b/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts index 3359591f07..92438e807b 100644 --- a/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts +++ b/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts @@ -11,8 +11,8 @@ import { assert, fs, path, workspaceRoot } from "../../internal/toolchain"; * multi-hour silent hold. * * 1. Read the release workflow and drop its comment lines. - * 2. Slice the two publish steps and every job block out of the workflow. - * 3. Assert each publish step's exact budget and one job-level timeout each. + * 2. Slice each exact job and publish-step mapping by YAML indentation. + * 3. Assert every mapping owns exactly its intended timeout budget. */ export const test_release_workflow_bounds_publish_timeouts = () => { const source = fs.readFileSync( @@ -21,30 +21,68 @@ export const test_release_workflow_bounds_publish_timeouts = () => { ); // Timeout keys live on uncommented lines by construction; dropping comments // keeps prose about timeouts from counting as the key itself. - const workflow = source - .split("\n") - .filter((line) => !/^\s*#/.test(line)) - .join("\n"); + const lines = source.split(/\r?\n/).filter((line) => !/^\s*#/.test(line)); for (const [step, budget] of [ - ["Publish VS Code Marketplace extension", "timeout-minutes: 10"], - ["Publish to npm", "timeout-minutes: 20"], - ] as Array<[string, string]>) { - const start = workflow.indexOf(`- name: ${step}`); - assert.notEqual(start, -1, `expected to find the ${step} step`); - const end = workflow.indexOf("- name:", start + 1); - const block = workflow.slice(start, end === -1 ? undefined : end); - assert.ok( - block.includes(budget), - `the ${step} step must declare ${budget}, got:\n${block}`, + ["Publish VS Code Marketplace extension", 10], + ["Publish to npm", 20], + ] as Array<[string, number]>) { + const block = selectYamlMapping(lines, `- name: ${step}`, 6); + assert.deepEqual( + block.filter((line) => /^ {8}timeout-minutes:/.test(line)), + [` timeout-minutes: ${budget}`], + `the ${step} step must own exactly timeout-minutes: ${budget}`, ); } - const jobLevelTimeouts = - workflow.match(/^ {4}timeout-minutes: \d+\r?$/gm) ?? []; - assert.equal( - jobLevelTimeouts.length, - 4, - `every release job must declare one job-level timeout-minutes, found: ${jobLevelTimeouts.join(", ") || "none"}`, - ); + for (const [job, budget] of [ + ["publish", 60], + ["wasm-smoke", 15], + ["vscode-smoke", 15], + ["release", 15], + ] as Array<[string, number]>) { + const block = selectYamlMapping(lines, `${job}:`, 2); + assert.deepEqual( + block.filter((line) => /^ {4}timeout-minutes:/.test(line)), + [` timeout-minutes: ${budget}`], + `the ${job} job must own exactly timeout-minutes: ${budget}`, + ); + } + + const jobNames = lines + .slice(lines.findIndex((line) => line === "jobs:") + 1) + .filter((line) => /^ {2}\S[^:]*:\r?$/.test(line)) + .map((line) => line.trim().slice(0, -1)); + assert.deepEqual(jobNames, [ + "publish", + "wasm-smoke", + "vscode-smoke", + "release", + ]); }; + +/** + * Select one YAML mapping whose header has exactly `indent` leading spaces. The + * block ends at the next non-empty line at the same or a shallower level. + */ +function selectYamlMapping( + lines: string[], + header: string, + indent: number, +): string[] { + const prefix = " ".repeat(indent); + const start = lines.findIndex((line) => line === `${prefix}${header}`); + assert.notEqual(start, -1, `expected to find ${header}`); + let end = start + 1; + while (end < lines.length) { + const line = lines[end]!; + if ( + line.trim().length !== 0 && + line.length - line.trimStart().length <= indent + ) { + break; + } + end += 1; + } + return lines.slice(start + 1, end); +} diff --git a/tests/test-unplugin/src/internal/transform-project-cache.ts b/tests/test-unplugin/src/internal/transform-project-cache.ts index 853ab6edba..fa76d82259 100644 --- a/tests/test-unplugin/src/internal/transform-project-cache.ts +++ b/tests/test-unplugin/src/internal/transform-project-cache.ts @@ -347,11 +347,11 @@ async function assertFirstModuleDeliveriesDoNotRehashProject(): Promise { * * Watch-input derivation must pay the graph's identity computations once per * generation; after that a delivery costs only its own memoized lookups. The - * probe counter simulates the macOS `pathIdentityKey` branch (one `existsSync` - * + one `realpathSync.native` per call) on any host, so the bound holds - * identically across CI platforms: before the fix every delivery re-walked the - * whole edge set with two syscalls per path, which scaled O(modules x edges) - * into the #970 residual stall. + * probe counter simulates both filesystem calls made by the macOS + * `pathIdentityKey` branch, `existsSync` and `realpathSync.native`, on any + * host, so the bound holds identically across CI platforms. Before the fix + * every delivery re-walked the whole edge set with two syscalls per path, which + * scaled O(modules x edges) into the #970 residual stall. */ async function assertSiblingDeliveriesDoNotReprobeGraph(): Promise { const { From 4ad0e2f91b9ed0ee0530baa15ac2d388561027e2 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 10:22:58 +0900 Subject: [PATCH 05/12] test(release): scope timeout assertions to jobs mapping Select the top-level jobs mapping before validating job and publish-step budgets. Add adversarial checks for same-named mappings outside jobs and trailing top-level mappings so timeout ownership cannot regress. --- ...elease_workflow_bounds_publish_timeouts.ts | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts b/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts index 92438e807b..f0f7a26130 100644 --- a/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts +++ b/tests/test-ttsc/src/features/platform/test_release_workflow_bounds_publish_timeouts.ts @@ -22,12 +22,46 @@ export const test_release_workflow_bounds_publish_timeouts = () => { // Timeout keys live on uncommented lines by construction; dropping comments // keeps prose about timeouts from counting as the key itself. const lines = source.split(/\r?\n/).filter((line) => !/^\s*#/.test(line)); + assertReleaseTimeouts(lines); + + // A same-named mapping outside `jobs` must never satisfy a job assertion. + const shadowed = [...lines]; + const jobsStart = shadowed.findIndex((line) => line === "jobs:"); + const releaseStart = shadowed.findIndex( + (line, index) => index > jobsStart && line === " release:", + ); + const releaseTimeout = shadowed.findIndex( + (line, index) => index > releaseStart && line === " timeout-minutes: 15", + ); + assert.notEqual(jobsStart, -1); + assert.notEqual(releaseStart, -1); + assert.notEqual(releaseTimeout, -1); + shadowed.splice(releaseTimeout, 1); + shadowed.splice( + jobsStart, + 0, + "shadow:", + " release:", + " timeout-minutes: 15", + ); + assert.throws( + () => assertReleaseTimeouts(shadowed), + /release job must own exactly timeout-minutes: 15/, + ); + + // A later top-level mapping and its children are not release jobs. + assertReleaseTimeouts([...lines, "postlude:", " phantom:"]); +}; + +function assertReleaseTimeouts(lines: string[]): void { + const jobs = selectYamlMapping(lines, "jobs:", 0); + const publish = selectYamlMapping(jobs, "publish:", 2); for (const [step, budget] of [ ["Publish VS Code Marketplace extension", 10], ["Publish to npm", 20], ] as Array<[string, number]>) { - const block = selectYamlMapping(lines, `- name: ${step}`, 6); + const block = selectYamlMapping(publish, `- name: ${step}`, 6); assert.deepEqual( block.filter((line) => /^ {8}timeout-minutes:/.test(line)), [` timeout-minutes: ${budget}`], @@ -41,7 +75,7 @@ export const test_release_workflow_bounds_publish_timeouts = () => { ["vscode-smoke", 15], ["release", 15], ] as Array<[string, number]>) { - const block = selectYamlMapping(lines, `${job}:`, 2); + const block = selectYamlMapping(jobs, `${job}:`, 2); assert.deepEqual( block.filter((line) => /^ {4}timeout-minutes:/.test(line)), [` timeout-minutes: ${budget}`], @@ -49,8 +83,7 @@ export const test_release_workflow_bounds_publish_timeouts = () => { ); } - const jobNames = lines - .slice(lines.findIndex((line) => line === "jobs:") + 1) + const jobNames = jobs .filter((line) => /^ {2}\S[^:]*:\r?$/.test(line)) .map((line) => line.trim().slice(0, -1)); assert.deepEqual(jobNames, [ @@ -59,7 +92,7 @@ export const test_release_workflow_bounds_publish_timeouts = () => { "vscode-smoke", "release", ]); -}; +} /** * Select one YAML mapping whose header has exactly `indent` leading spaces. The From c23e694d70b5b9f208fd84bf75e98f1cb900e230 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 10:36:29 +0900 Subject: [PATCH 06/12] ci(nestia): pin the verified integration revision The moving nestia master checkout inherited an upstream openapi_v2 failure that was already red in nestia's own SDK lane. Pin the last full-green merge and lock the checkout contract with a repository test. --- .github/workflows/nestia.yml | 16 ++++++--- ...orkflow_pins_verified_upstream_revision.ts | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts diff --git a/.github/workflows/nestia.yml b/.github/workflows/nestia.yml index 7cf654d653..0617fa644f 100644 --- a/.github/workflows/nestia.yml +++ b/.github/workflows/nestia.yml @@ -90,10 +90,18 @@ jobs: name: ttsc-tarballs path: experimental/tarballs - - name: Clone nestia - run: | - rm -rf experimental/nestia - git clone --depth=1 --branch master https://github.com/samchon/nestia experimental/nestia + # Keep this integration gate attributable to ttsc. nestia master became + # red in its own sdk lane after this revision (PR #1594, openapi_v2), so a + # moving clone made every ttsc change inherit an unrelated upstream + # failure. Advance the pin only to a revision with a reviewed green full + # suite; 3b27e69b is the merge of nestia #1593 and its complete test run + # 29959327169 passed. + - name: Check out verified nestia integration revision + uses: actions/checkout@v4 + with: + repository: samchon/nestia + ref: 3b27e69b69dea3f102315042dce87c18d81be74a + path: experimental/nestia - name: Use local ttsc tarballs working-directory: experimental/nestia diff --git a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts new file mode 100644 index 0000000000..fb673b5ca9 --- /dev/null +++ b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts @@ -0,0 +1,35 @@ +import { assert, fs, path, workspaceRoot } from "../../internal/toolchain"; + +/** + * Verifies the nestia integration checks out a reviewed green revision. + * + * Locks the red CI discovered by samchon/ttsc#1009: cloning nestia master made + * ttsc inherit an upstream `openapi_v2` failure that was already red in + * nestia's own sdk lane. The pinned revision is the merge of nestia #1593, + * whose complete upstream test run 29959327169 passed. + * + * 1. Select the named nestia checkout step. + * 2. Assert its repository, revision, and destination exactly. + * 3. Reject a moving `git clone` fallback. + */ +export const test_nestia_workflow_pins_verified_upstream_revision = () => { + const source = fs.readFileSync( + path.join(workspaceRoot, ".github", "workflows", "nestia.yml"), + "utf8", + ); + const marker = " - name: Check out verified nestia integration revision"; + const start = source.indexOf(marker); + assert.notEqual(start, -1, "expected the pinned nestia checkout step"); + const next = source.indexOf("\n - ", start + marker.length); + const block = source.slice(start, next === -1 ? undefined : next); + + for (const expected of [ + " uses: actions/checkout@v4", + " repository: samchon/nestia", + " ref: 3b27e69b69dea3f102315042dce87c18d81be74a", + " path: experimental/nestia", + ]) { + assert.ok(block.includes(expected), `expected checkout line: ${expected}`); + } + assert.doesNotMatch(source, /git clone\b[^\n]*samchon\/nestia/); +}; From d23b3b58701029f00ce84b6d59b183b67538712a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 10:44:51 +0900 Subject: [PATCH 07/12] test(nestia): require exact checkout ownership Strengthen the pinned-integration regression so repository and path prefixes cannot masquerade as exact values, and normalize shell continuations before rejecting a moving nestia clone. --- ...estia_workflow_pins_verified_upstream_revision.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts index fb673b5ca9..12e07fa064 100644 --- a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts +++ b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts @@ -22,6 +22,7 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { assert.notEqual(start, -1, "expected the pinned nestia checkout step"); const next = source.indexOf("\n - ", start + marker.length); const block = source.slice(start, next === -1 ? undefined : next); + const blockLines = block.split(/\r?\n/); for (const expected of [ " uses: actions/checkout@v4", @@ -29,7 +30,14 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { " ref: 3b27e69b69dea3f102315042dce87c18d81be74a", " path: experimental/nestia", ]) { - assert.ok(block.includes(expected), `expected checkout line: ${expected}`); + assert.equal( + blockLines.filter((line) => line === expected).length, + 1, + `expected exactly one checkout line: ${expected}`, + ); } - assert.doesNotMatch(source, /git clone\b[^\n]*samchon\/nestia/); + assert.doesNotMatch( + source.replace(/\\\r?\n\s*/g, " "), + /git clone\b[^\n]*samchon\/nestia/, + ); }; From f7db90bb6b08f0e4813085433e81403ae7a00bf6 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 10:54:14 +0900 Subject: [PATCH 08/12] test(nestia): bind checkout assertions to yaml owner Require the full checkout/with mapping as one exact structure and exercise both wrong-owner and folded-scalar clone bypasses. Extract workflow run values with YAML block-scalar and shell-continuation semantics before rejecting clone fallbacks. --- ...orkflow_pins_verified_upstream_revision.ts | 120 +++++++++++++++--- 1 file changed, 104 insertions(+), 16 deletions(-) diff --git a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts index 12e07fa064..be0efcec0e 100644 --- a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts +++ b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts @@ -17,27 +17,115 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { path.join(workspaceRoot, ".github", "workflows", "nestia.yml"), "utf8", ); + assertPinnedNestiaCheckout(source); + assertNoMovingNestiaClone(source); + + const normalized = source.replace(/\r\n/g, "\n"); const marker = " - name: Check out verified nestia integration revision"; - const start = source.indexOf(marker); - assert.notEqual(start, -1, "expected the pinned nestia checkout step"); - const next = source.indexOf("\n - ", start + marker.length); - const block = source.slice(start, next === -1 ? undefined : next); - const blockLines = block.split(/\r?\n/); + assert.throws( + () => + assertPinnedNestiaCheckout( + normalized.replace( + " with:\n repository: samchon/nestia", + " env:\n repository: samchon/nestia", + ), + ), + /expected the exact pinned nestia checkout mapping/, + ); + assert.throws( + () => + assertNoMovingNestiaClone( + normalized.replace( + marker, + [ + " - name: Moving nestia clone probe", + " run: >", + " git clone", + " https://github.com/samchon/nestia.git experimental/nestia", + marker, + ].join("\n"), + ), + ), + /moving nestia clone/, + ); +}; - for (const expected of [ +/** Assert the complete checkout action input mapping as one owned structure. */ +function assertPinnedNestiaCheckout(source: string): void { + const normalized = source.replace(/\r\n/g, "\n"); + const marker = " - name: Check out verified nestia integration revision"; + assert.equal( + normalized.split(marker).length - 1, + 1, + "expected exactly one pinned nestia checkout step", + ); + const start = normalized.indexOf(marker); + const next = normalized.indexOf("\n - ", start + marker.length); + const block = normalized.slice(start, next === -1 ? undefined : next); + const expected = [ + marker, " uses: actions/checkout@v4", + " with:", " repository: samchon/nestia", " ref: 3b27e69b69dea3f102315042dce87c18d81be74a", " path: experimental/nestia", - ]) { - assert.equal( - blockLines.filter((line) => line === expected).length, - 1, - `expected exactly one checkout line: ${expected}`, + ].join("\n"); + assert.equal( + block.trimEnd(), + expected, + "expected the exact pinned nestia checkout mapping", + ); +} + +/** Reject an active shell command that replaces the pin with a moving clone. */ +function assertNoMovingNestiaClone(source: string): void { + for (const command of selectWorkflowRunCommands(source)) { + assert.doesNotMatch( + command, + /\bgit\s+clone\b/, + "release integration must not use a moving nestia clone", ); } - assert.doesNotMatch( - source.replace(/\\\r?\n\s*/g, " "), - /git clone\b[^\n]*samchon\/nestia/, - ); -}; +} + +/** + * Extract GitHub Actions `run:` values, folding `>` blocks and joining shell + * backslash continuations in `|` blocks the same way the command runner does. + */ +function selectWorkflowRunCommands(source: string): string[] { + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const output: string[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const match = /^(\s*)(?:-\s+)?run:\s*(.*?)\s*$/.exec(line); + if (match === null) { + continue; + } + const indent = match[1]!.length; + const value = match[2]!; + const indicator = /^[>|]/.exec(value)?.[0]; + if (indicator === undefined) { + output.push(value); + continue; + } + + const body: string[] = []; + while (index + 1 < lines.length) { + const next = lines[index + 1]!; + if ( + next.trim().length !== 0 && + next.length - next.trimStart().length <= indent + ) { + break; + } + body.push(next); + index += 1; + } + const command = + indicator === ">" + ? body.map((entry) => entry.trim()).join(" ") + : body.join("\n"); + output.push(command.replace(/\\\n\s*/g, " ")); + } + return output; +} From 2509b36c2db81dc6d04968af1ae06672d8e41d68 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 11:03:48 +0900 Subject: [PATCH 09/12] test(nestia): parse workflow ownership from yaml Use the workspace's YAML parser as a direct test dependency so checkout inputs and run commands are asserted after YAML folding, including wrong-owner, folded-block, and plain-scalar bypass cases. Keep the lockfile change scoped to the test importer. --- pnpm-lock.yaml | 3 + tests/test-ttsc/package.json | 3 +- ...orkflow_pins_verified_upstream_revision.ts | 175 ++++++++++-------- 3 files changed, 98 insertions(+), 83 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a08274661a..4dea4ee6f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,6 +580,9 @@ importers: typescript: specifier: catalog:typescript version: 7.0.2 + yaml: + specifier: ^2.9.0 + version: 2.9.0 tests/test-unplugin: devDependencies: diff --git a/tests/test-ttsc/package.json b/tests/test-ttsc/package.json index a104b85efb..674a2e1ab0 100644 --- a/tests/test-ttsc/package.json +++ b/tests/test-ttsc/package.json @@ -15,6 +15,7 @@ "@ttsc/testing": "workspace:*", "@types/node": "catalog:utils", "ttsc": "workspace:*", - "typescript": "catalog:typescript" + "typescript": "catalog:typescript", + "yaml": "^2.9.0" } } diff --git a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts index be0efcec0e..51533c649a 100644 --- a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts +++ b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts @@ -1,3 +1,5 @@ +import { parse } from "yaml"; + import { assert, fs, path, workspaceRoot } from "../../internal/toolchain"; /** @@ -17,115 +19,124 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { path.join(workspaceRoot, ".github", "workflows", "nestia.yml"), "utf8", ); - assertPinnedNestiaCheckout(source); - assertNoMovingNestiaClone(source); + assertPinnedNestiaCheckout(parseWorkflow(source)); + assertNoMovingNestiaClone(parseWorkflow(source)); const normalized = source.replace(/\r\n/g, "\n"); const marker = " - name: Check out verified nestia integration revision"; assert.throws( () => assertPinnedNestiaCheckout( - normalized.replace( - " with:\n repository: samchon/nestia", - " env:\n repository: samchon/nestia", + parseWorkflow( + normalized.replace( + " with:\n repository: samchon/nestia", + " env:\n repository: samchon/nestia", + ), ), ), /expected the exact pinned nestia checkout mapping/, ); - assert.throws( - () => - assertNoMovingNestiaClone( - normalized.replace( - marker, - [ - " - name: Moving nestia clone probe", - " run: >", - " git clone", - " https://github.com/samchon/nestia.git experimental/nestia", - marker, - ].join("\n"), + for (const run of [ + [ + " - name: Folded moving nestia clone probe", + " run: >", + " git clone", + " https://github.com/samchon/nestia.git experimental/nestia", + ], + [ + " - name: Plain moving nestia clone probe", + " run: git", + " clone https://github.com/samchon/nestia.git experimental/nestia", + ], + ]) { + assert.throws( + () => + assertNoMovingNestiaClone( + parseWorkflow( + normalized.replace(marker, [...run, marker].join("\n")), + ), ), - ), - /moving nestia clone/, - ); + /moving nestia clone/, + ); + } }; +interface IWorkflow { + jobs: Record; +} + +interface IWorkflowJob { + steps?: IWorkflowStep[]; +} + +interface IWorkflowStep { + name?: unknown; + run?: unknown; + uses?: unknown; + with?: unknown; +} + +/** Parse a workflow and prove the mappings needed by this regression test. */ +function parseWorkflow(source: string): IWorkflow { + const workflow: unknown = parse(source); + assert.ok(isRecord(workflow), "expected a workflow mapping"); + assert.ok(isRecord(workflow.jobs), "expected a jobs mapping"); + return workflow as unknown as IWorkflow; +} + /** Assert the complete checkout action input mapping as one owned structure. */ -function assertPinnedNestiaCheckout(source: string): void { - const normalized = source.replace(/\r\n/g, "\n"); - const marker = " - name: Check out verified nestia integration revision"; +function assertPinnedNestiaCheckout(workflow: IWorkflow): void { + const nestia = workflow.jobs.nestia; + assert.ok(isRecord(nestia), "expected the nestia job"); + assert.ok(Array.isArray(nestia.steps), "expected nestia job steps"); + assert.ok(nestia.steps.every(isRecord), "expected step mappings"); + + const expected: IWorkflowStep = { + name: "Check out verified nestia integration revision", + uses: "actions/checkout@v4", + with: { + repository: "samchon/nestia", + ref: "3b27e69b69dea3f102315042dce87c18d81be74a", + path: "experimental/nestia", + }, + }; + const owners = nestia.steps.filter( + (step) => + step.name === expected.name || + (isRecord(step.with) && + (step.with.repository === "samchon/nestia" || + step.with.path === "experimental/nestia")), + ); assert.equal( - normalized.split(marker).length - 1, + owners.length, 1, "expected exactly one pinned nestia checkout step", ); - const start = normalized.indexOf(marker); - const next = normalized.indexOf("\n - ", start + marker.length); - const block = normalized.slice(start, next === -1 ? undefined : next); - const expected = [ - marker, - " uses: actions/checkout@v4", - " with:", - " repository: samchon/nestia", - " ref: 3b27e69b69dea3f102315042dce87c18d81be74a", - " path: experimental/nestia", - ].join("\n"); - assert.equal( - block.trimEnd(), + assert.deepEqual( + owners[0], expected, "expected the exact pinned nestia checkout mapping", ); } /** Reject an active shell command that replaces the pin with a moving clone. */ -function assertNoMovingNestiaClone(source: string): void { - for (const command of selectWorkflowRunCommands(source)) { - assert.doesNotMatch( - command, - /\bgit\s+clone\b/, - "release integration must not use a moving nestia clone", - ); - } -} - -/** - * Extract GitHub Actions `run:` values, folding `>` blocks and joining shell - * backslash continuations in `|` blocks the same way the command runner does. - */ -function selectWorkflowRunCommands(source: string): string[] { - const lines = source.replace(/\r\n/g, "\n").split("\n"); - const output: string[] = []; - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]!; - const match = /^(\s*)(?:-\s+)?run:\s*(.*?)\s*$/.exec(line); - if (match === null) { - continue; - } - const indent = match[1]!.length; - const value = match[2]!; - const indicator = /^[>|]/.exec(value)?.[0]; - if (indicator === undefined) { - output.push(value); +function assertNoMovingNestiaClone(workflow: IWorkflow): void { + for (const job of Object.values(workflow.jobs)) { + if (!Array.isArray(job.steps)) { continue; } - - const body: string[] = []; - while (index + 1 < lines.length) { - const next = lines[index + 1]!; - if ( - next.trim().length !== 0 && - next.length - next.trimStart().length <= indent - ) { - break; + for (const step of job.steps) { + if (isRecord(step) && typeof step.run === "string") { + assert.doesNotMatch( + step.run, + /\bgit\s+clone\b/, + "release integration must not use a moving nestia clone", + ); } - body.push(next); - index += 1; } - const command = - indicator === ">" - ? body.map((entry) => entry.trim()).join(" ") - : body.join("\n"); - output.push(command.replace(/\\\n\s*/g, " ")); } - return output; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } From 15f728b4bda02bdf5cc9f3c67731d8457ae34922 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 11:11:19 +0900 Subject: [PATCH 10/12] test(nestia): cover workflow-wide and shell ownership Keep the pinned checkout unique across every parsed job, normalize shell backslash continuations before clone detection, and lock both bypasses with adversarial cases. --- ...orkflow_pins_verified_upstream_revision.ts | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts index 51533c649a..1da8133810 100644 --- a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts +++ b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts @@ -36,6 +36,24 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { ), /expected the exact pinned nestia checkout mapping/, ); + const duplicated = parseWorkflow(source); + duplicated.jobs.probe = { + steps: [ + { + name: "Check out verified nestia integration revision", + uses: "actions/checkout@v4", + with: { + repository: "samchon/nestia", + ref: "3b27e69b69dea3f102315042dce87c18d81be74a", + path: "experimental/nestia", + }, + }, + ], + }; + assert.throws( + () => assertPinnedNestiaCheckout(duplicated), + /expected exactly one pinned nestia checkout step/, + ); for (const run of [ [ " - name: Folded moving nestia clone probe", @@ -48,6 +66,12 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { " run: git", " clone https://github.com/samchon/nestia.git experimental/nestia", ], + [ + " - name: Continued moving nestia clone probe", + " run: |", + " git \\", + " clone https://github.com/samchon/nestia.git experimental/nestia", + ], ]) { assert.throws( () => @@ -100,7 +124,7 @@ function assertPinnedNestiaCheckout(workflow: IWorkflow): void { path: "experimental/nestia", }, }; - const owners = nestia.steps.filter( + const owners = selectWorkflowSteps(workflow).filter( (step) => step.name === expected.name || (isRecord(step.with) && @@ -117,6 +141,10 @@ function assertPinnedNestiaCheckout(workflow: IWorkflow): void { expected, "expected the exact pinned nestia checkout mapping", ); + assert.ok( + nestia.steps.some((step) => Object.is(step, owners[0])), + "expected the pinned checkout to belong to the nestia job", + ); } /** Reject an active shell command that replaces the pin with a moving clone. */ @@ -127,9 +155,10 @@ function assertNoMovingNestiaClone(workflow: IWorkflow): void { } for (const step of job.steps) { if (isRecord(step) && typeof step.run === "string") { + const command = step.run.replace(/\\\n\s*/g, " "); assert.doesNotMatch( - step.run, - /\bgit\s+clone\b/, + command, + /\bgit\b[^\n;&|]*\bclone\b/, "release integration must not use a moving nestia clone", ); } @@ -137,6 +166,13 @@ function assertNoMovingNestiaClone(workflow: IWorkflow): void { } } +/** Flatten valid parsed step mappings across the complete workflow. */ +function selectWorkflowSteps(workflow: IWorkflow): IWorkflowStep[] { + return Object.values(workflow.jobs).flatMap((job) => + Array.isArray(job.steps) ? job.steps.filter(isRecord) : [], + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } From c6932349dbf814a6c6a519ac495467a4794d22be Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 11:17:12 +0900 Subject: [PATCH 11/12] test(nestia): preserve shell token semantics Remove shell continuation pairs before matching the Git subcommand, constrain clone detection to command boundaries and recognized global options, and cover token-split clones without rejecting harmless config or echo text. --- ...orkflow_pins_verified_upstream_revision.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts index 1da8133810..8d28eb5b8b 100644 --- a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts +++ b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts @@ -54,6 +54,14 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { () => assertPinnedNestiaCheckout(duplicated), /expected exactly one pinned nestia checkout step/, ); + const harmless = parseWorkflow(source); + harmless.jobs.probe = { + steps: [ + { run: "git config alias.clone 'clone --no-hardlinks'" }, + { run: 'echo "git clone is forbidden here"' }, + ], + }; + assert.doesNotThrow(() => assertNoMovingNestiaClone(harmless)); for (const run of [ [ " - name: Folded moving nestia clone probe", @@ -72,6 +80,16 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { " git \\", " clone https://github.com/samchon/nestia.git experimental/nestia", ], + [ + " - name: Token-continued moving nestia clone probe", + " run: |", + " git cl\\", + " one https://github.com/samchon/nestia.git experimental/nestia", + ], + [ + " - name: Optioned moving nestia clone probe", + " run: git -c protocol.version=2 clone https://github.com/samchon/nestia.git experimental/nestia", + ], ]) { assert.throws( () => @@ -155,10 +173,10 @@ function assertNoMovingNestiaClone(workflow: IWorkflow): void { } for (const step of job.steps) { if (isRecord(step) && typeof step.run === "string") { - const command = step.run.replace(/\\\n\s*/g, " "); + const command = step.run.replace(/\\\n/g, ""); assert.doesNotMatch( command, - /\bgit\b[^\n;&|]*\bclone\b/, + /(?:^|[\n;&|])\s*git(?:(?:\s+(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+)|(?:\s+--(?:no-pager|paginate|no-replace-objects|bare|literal-pathspecs|glob-pathspecs|noglob-pathspecs|icase-pathspecs))|(?:\s+--(?:exec-path|git-dir|work-tree|namespace)=\S+))*\s+clone\b/, "release integration must not use a moving nestia clone", ); } From 976d6c3d9b4a935be4f61be4e3eca6d51d3fdde3 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 26 Jul 2026 11:23:54 +0900 Subject: [PATCH 12/12] test(nestia): assert checkout structure without shell parsing Drop the incomplete shell-command blacklist and keep the durable semantic contract: one exact checkout owner across the parsed workflow, belonging to the nestia job, with wrong-owner and cross-job duplicate regressions. --- ...orkflow_pins_verified_upstream_revision.ts | 74 +------------------ 1 file changed, 3 insertions(+), 71 deletions(-) diff --git a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts index 8d28eb5b8b..9b374a629b 100644 --- a/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts +++ b/tests/test-ttsc/src/features/platform/test_nestia_workflow_pins_verified_upstream_revision.ts @@ -11,8 +11,9 @@ import { assert, fs, path, workspaceRoot } from "../../internal/toolchain"; * whose complete upstream test run 29959327169 passed. * * 1. Select the named nestia checkout step. - * 2. Assert its repository, revision, and destination exactly. - * 3. Reject a moving `git clone` fallback. + * 2. Assert its repository, revision, and destination exactly and uniquely across + * the workflow. + * 3. Prove wrong ownership and a cross-job duplicate cannot satisfy the test. */ export const test_nestia_workflow_pins_verified_upstream_revision = () => { const source = fs.readFileSync( @@ -20,10 +21,8 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { "utf8", ); assertPinnedNestiaCheckout(parseWorkflow(source)); - assertNoMovingNestiaClone(parseWorkflow(source)); const normalized = source.replace(/\r\n/g, "\n"); - const marker = " - name: Check out verified nestia integration revision"; assert.throws( () => assertPinnedNestiaCheckout( @@ -54,53 +53,6 @@ export const test_nestia_workflow_pins_verified_upstream_revision = () => { () => assertPinnedNestiaCheckout(duplicated), /expected exactly one pinned nestia checkout step/, ); - const harmless = parseWorkflow(source); - harmless.jobs.probe = { - steps: [ - { run: "git config alias.clone 'clone --no-hardlinks'" }, - { run: 'echo "git clone is forbidden here"' }, - ], - }; - assert.doesNotThrow(() => assertNoMovingNestiaClone(harmless)); - for (const run of [ - [ - " - name: Folded moving nestia clone probe", - " run: >", - " git clone", - " https://github.com/samchon/nestia.git experimental/nestia", - ], - [ - " - name: Plain moving nestia clone probe", - " run: git", - " clone https://github.com/samchon/nestia.git experimental/nestia", - ], - [ - " - name: Continued moving nestia clone probe", - " run: |", - " git \\", - " clone https://github.com/samchon/nestia.git experimental/nestia", - ], - [ - " - name: Token-continued moving nestia clone probe", - " run: |", - " git cl\\", - " one https://github.com/samchon/nestia.git experimental/nestia", - ], - [ - " - name: Optioned moving nestia clone probe", - " run: git -c protocol.version=2 clone https://github.com/samchon/nestia.git experimental/nestia", - ], - ]) { - assert.throws( - () => - assertNoMovingNestiaClone( - parseWorkflow( - normalized.replace(marker, [...run, marker].join("\n")), - ), - ), - /moving nestia clone/, - ); - } }; interface IWorkflow { @@ -113,7 +65,6 @@ interface IWorkflowJob { interface IWorkflowStep { name?: unknown; - run?: unknown; uses?: unknown; with?: unknown; } @@ -165,25 +116,6 @@ function assertPinnedNestiaCheckout(workflow: IWorkflow): void { ); } -/** Reject an active shell command that replaces the pin with a moving clone. */ -function assertNoMovingNestiaClone(workflow: IWorkflow): void { - for (const job of Object.values(workflow.jobs)) { - if (!Array.isArray(job.steps)) { - continue; - } - for (const step of job.steps) { - if (isRecord(step) && typeof step.run === "string") { - const command = step.run.replace(/\\\n/g, ""); - assert.doesNotMatch( - command, - /(?:^|[\n;&|])\s*git(?:(?:\s+(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+)|(?:\s+--(?:no-pager|paginate|no-replace-objects|bare|literal-pathspecs|glob-pathspecs|noglob-pathspecs|icase-pathspecs))|(?:\s+--(?:exec-path|git-dir|work-tree|namespace)=\S+))*\s+clone\b/, - "release integration must not use a moving nestia clone", - ); - } - } - } -} - /** Flatten valid parsed step mappings across the complete workflow. */ function selectWorkflowSteps(workflow: IWorkflow): IWorkflowStep[] { return Object.values(workflow.jobs).flatMap((job) =>