Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 80 additions & 60 deletions packages/core/native/transform/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"os"
"path/filepath"
"sync"
"time"

shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
Expand Down Expand Up @@ -68,23 +69,54 @@ func runBuild(args []string) int {
return 2
}
if len(diags) > 0 {
if prog != nil {
prog.Close()
}
driver.WritePrettyDiagnostics(stderr, diags, cwd)
return 2
}
defer prog.Close()
releaseTypiaRegistries := registerTypiaDefaultLibraryClassifier(prog)
defer releaseTypiaRegistries()
if profile {
started = time.Now()
}
if diags := prog.Diagnostics(); len(diags) > 0 {
profileBuildStep(profile, "diagnostics", started)
prog.Close()
driver.WritePrettyDiagnostics(stderr, diags, cwd)
return 2
}
profileBuildStep(profile, "diagnostics", started)

shouldEmit := !prog.ParsedConfig.ParsedConfig.CompilerOptions.NoEmit.IsTrue()
if !shouldEmit {
// Plugin transformers are part of tsgo's emit pipeline. Reload with emit
// enabled so check/noEmit traverses the same source set, then discard every
// generated output below. The original program already proved the project's
// diagnostics; diagnosing this private reload would reject valid analysis-
// only options such as allowImportingTsExtensions.
prog.Close()
if profile {
started = time.Now()
}
prog, diags, err = driver.LoadProgram(cwd, *tsconfigPath, driver.LoadProgramOptions{
ForceEmit: true,
OutDir: *outDir,
})
profileBuildStep(profile, "load-transform-program", started)
if err != nil {
fmt.Fprintf(stderr, "ttsc-nestia build: %v\n", err)
return 2
}
if len(diags) > 0 {
if prog != nil {
prog.Close()
}
driver.WritePrettyDiagnostics(stderr, diags, cwd)
return 2
}
}
defer prog.Close()
releaseTypiaRegistries := registerTypiaDefaultLibraryClassifier(prog)
defer releaseTypiaRegistries()
if !*quiet {
fmt.Fprintf(
stdout,
Expand All @@ -97,9 +129,6 @@ func runBuild(args []string) int {
shouldEmit,
)
}
if !shouldEmit {
return 0
}

// AST-integration emit: typia's per-file transformer and @nestia/core's own
// per-file transformer both run inside tsgo's emit pipeline (sharing the
Expand Down Expand Up @@ -132,10 +161,20 @@ func runBuild(args []string) int {
transforms := append([]driver.PluginTransform{typiaTransform, coreTransform}, contributorTransforms...)

emitted := []string{}
pending := []buildPendingOutput{}
var writeMu sync.Mutex
writeFile := shimcompiler.WriteFile(func(fileName, text string, data *shimcompiler.WriteFileData) error {
_ = data
emitted = append(emitted, fileName)
return driver.DefaultWriteFile(fileName, text)
if shouldEmit {
// TypeScript-Go emits declarations in parallel. Serialize the shared
// slices so buffering cannot lose an artifact or race while the
// declaration workers call this callback concurrently.
writeMu.Lock()
defer writeMu.Unlock()
emitted = append(emitted, fileName)
pending = append(pending, buildPendingOutput{FileName: fileName, Text: text})
}
return nil
})

// Declaration emit: ttsc delegates the whole emit of a transform-plugin
Expand All @@ -145,7 +184,7 @@ func runBuild(args []string) int {
// core runtime transforms never change the public type surface, so the
// declarations are taken from the pristine program — done before the JS
// transform runs so it reads the un-mutated AST.
if prog.ParsedConfig.ParsedConfig.CompilerOptions.Declaration.IsTrue() {
if shouldEmit && prog.ParsedConfig.ParsedConfig.CompilerOptions.Declaration.IsTrue() {
if profile {
started = time.Now()
}
Expand All @@ -162,42 +201,55 @@ func runBuild(args []string) int {
fmt.Fprintf(stderr, "ttsc-nestia build: emit failed: %v\n", err)
return 3
}
if len(transformDiags) > 0 {
WriteTypiaTransformDiagnostics(stderr, transformDiags, cwd)
return 3
}
emitHasError := false
for _, d := range eDiags {
fmt.Fprintln(stderr, " -", d.String())
if d.IsError() {
emitHasError = true
}
}
if len(transformDiags) > 0 {
WriteTypiaTransformDiagnostics(stderr, transformDiags, cwd)
return 3
}
if emitHasError {
return 3
}
if *manifestPath != "" {
data, err := json.Marshal(emitted)
if err != nil {
fmt.Fprintf(stderr, "ttsc-nestia build: manifest marshal failed: %v\n", err)
return 3
if shouldEmit {
for _, output := range pending {
if err := driver.DefaultWriteFile(output.FileName, output.Text); err != nil {
fmt.Fprintf(stderr, "ttsc-nestia build: emit write failed: %v\n", err)
return 3
}
}
if err := os.MkdirAll(filepath.Dir(*manifestPath), 0o755); err != nil {
fmt.Fprintf(stderr, "ttsc-nestia build: manifest mkdir failed: %v\n", err)
return 3
if *manifestPath != "" {
data, err := json.Marshal(emitted)
if err != nil {
fmt.Fprintf(stderr, "ttsc-nestia build: manifest marshal failed: %v\n", err)
return 3
}
if err := os.MkdirAll(filepath.Dir(*manifestPath), 0o755); err != nil {
fmt.Fprintf(stderr, "ttsc-nestia build: manifest mkdir failed: %v\n", err)
return 3
}
if err := os.WriteFile(*manifestPath, data, 0o644); err != nil {
fmt.Fprintf(stderr, "ttsc-nestia build: manifest write failed: %v\n", err)
return 3
}
}
if err := os.WriteFile(*manifestPath, data, 0o644); err != nil {
fmt.Fprintf(stderr, "ttsc-nestia build: manifest write failed: %v\n", err)
return 3
if !*quiet {
fmt.Fprintf(stdout, "// ttsc-nestia build: emitted=%d files\n", len(emitted))
}
}
if !*quiet {
fmt.Fprintf(stdout, "// ttsc-nestia build: emitted=%d files\n", len(emitted))
}
profileBuildStep(profile, "total", totalStarted)
return 0
}

type buildPendingOutput struct {
FileName string
Text string
}

// emitDeclarations runs tsgo's standard declaration emitter for the program.
// tsgo's MarkLinkedReferences pass can nil-panic on some cross-module reference
// shapes (e.g. a nestia.config.ts that calls NestFactory.create, compiled by the
Expand All @@ -220,39 +272,7 @@ func emitDeclarations(prog *driver.Program, writeFile shimcompiler.WriteFile) {
}

func runCheck(args []string) int {
fs := flag.NewFlagSet("check", flag.ContinueOnError)
fs.SetOutput(stderr)
tsconfigPath := fs.String("tsconfig", "tsconfig.json", "path to tsconfig.json")
cwdOverride := fs.String("cwd", "", "override the working directory")
pluginsJSON := fs.String("plugins-json", "", "ordered ttsc plugin payload")
if err := fs.Parse(args); err != nil {
return 2
}
if _, err := plugin.ParsePlan(*pluginsJSON); err != nil {
fmt.Fprintf(stderr, "ttsc-nestia check: %v\n", err)
return 2
}
cwd, ok := resolveCWD("ttsc-nestia check", *cwdOverride)
if !ok {
return 2
}
prog, diags, err := driver.LoadProgram(cwd, *tsconfigPath, driver.LoadProgramOptions{
ForceNoEmit: true,
})
if err != nil {
fmt.Fprintf(stderr, "ttsc-nestia check: %v\n", err)
return 2
}
if len(diags) > 0 {
driver.WritePrettyDiagnostics(stderr, diags, cwd)
return 2
}
defer prog.Close()
if diags := prog.Diagnostics(); len(diags) > 0 {
driver.WritePrettyDiagnostics(stderr, diags, cwd)
return 2
}
return 0
return runBuild(append([]string{"--noEmit"}, args...))
}

type Diagnostic struct {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package test

import (
"os"
"strings"
"testing"
)

// TestBuildNoEmitPreservesAnalysisOnlyOptions verifies the private transform
// traversal does not invalidate compiler options that are legal only in a
// no-emit project.
//
// `allowImportingTsExtensions` is accepted with noEmit but rejected by a normal
// emitting configuration. The private ForceEmit program exists only to invoke
// transformers, so repeating TypeScript diagnostics against that overridden
// configuration would turn a valid analysis-only build into a false failure.
//
// 1. Create a valid no-emit TypedRoute project with the analysis-only option.
// 2. Run the native build path and require a clean exit.
// 3. Prove the private traversal publishes no output or build metadata.
func TestBuildNoEmitPreservesAnalysisOnlyOptions(t *testing.T) {
project := writeLlmRouteBuildProject(t, llmRouteBuildProjectOptions{
NoEmit: true,
AllowImportingTsExtensions: true,
Valid: true,
})
out, errText, code := runCoreNative([]string{
"build",
"--cwd", project.Root,
"--tsconfig", "tsconfig.json",
"--manifest", project.Manifest,
"--plugins-json", project.PluginsJSON,
})
if code != 0 {
t.Fatalf("valid analysis-only project failed with code %d\nstdout=%s\nstderr=%s", code, out, errText)
}
if strings.TrimSpace(out) != "" || strings.TrimSpace(errText) != "" {
t.Fatalf("quiet analysis-only build wrote output:\nstdout=%s\nstderr=%s", out, errText)
}
for _, path := range []string{project.OutDir, project.BuildInfo, project.Manifest} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("analysis-only traversal published %s: %v", path, err)
}
}
}
101 changes: 101 additions & 0 deletions packages/core/test/build_no_emit_reports_llm_route_diagnostic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package test

import (
"os"
"path/filepath"
"strings"
"testing"
)

// TestBuildNoEmitReportsLlmRouteDiagnostic verifies every analysis-only entry
// path runs the same @TypedRoute LLM validation as an emitting build.
//
// A tuple response is valid JSON but invalid as an LLM schema. Before this
// regression fix, check, an explicit --noEmit, and tsconfig-owned noEmit all
// returned success before the core transformer ran, contradicting the runtime
// error's advice to use `ttsc --noEmit` for the underlying diagnostic.
//
// 1. Create equivalent tuple-return TypedRoute projects for all no-emit paths.
// 2. Require the preserved source location, decorator code, and LLM reason.
// 3. Prove every analysis-only path publishes no compiler artifact.
func TestBuildNoEmitReportsLlmRouteDiagnostic(t *testing.T) {
cases := []struct {
name string
configured bool
verbose bool
command func(llmRouteBuildProject) []string
}{
{
name: "check-command",
command: func(project llmRouteBuildProject) []string {
return []string{
"check",
"--cwd", project.Root,
"--tsconfig", "tsconfig.json",
"--manifest", project.Manifest,
"--plugins-json", project.PluginsJSON,
}
},
},
{
name: "explicit-no-emit",
verbose: true,
command: func(project llmRouteBuildProject) []string {
return []string{
"build",
"--cwd", project.Root,
"--tsconfig", "tsconfig.json",
"--noEmit",
"--manifest", project.Manifest,
"--verbose",
"--plugins-json", project.PluginsJSON,
}
},
},
{
name: "configured-no-emit",
configured: true,
command: func(project llmRouteBuildProject) []string {
return []string{
"build",
"--cwd", project.Root,
"--tsconfig", "tsconfig.json",
"--manifest", project.Manifest,
"--plugins-json", project.PluginsJSON,
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
project := writeLlmRouteBuildProject(t, llmRouteBuildProjectOptions{
NoEmit: tc.configured,
})
out, errText, code := runCoreNative(tc.command(project))
if code != 3 {
t.Fatalf("analysis-only LLM transform should fail with code 3, got %d\nstdout=%s\nstderr=%s", code, out, errText)
}
normalized := filepath.ToSlash(errText)
mustContainAll(t, normalized,
"src/main.ts:8:4 - error TS(nestia.core.TypedRoute): unsupported type detected",
"- IResponse.pair: [string, number]",
"- LLM schema does not support tuple type.",
)
if strings.Contains(errText, "JSON does not support tuple type") {
t.Fatalf("tuple witness should isolate the LLM validator:\n%s", errText)
}
if tc.verbose {
if !strings.Contains(out, "emit=false") {
t.Fatalf("verbose no-emit summary missing:\n%s", out)
}
} else if strings.TrimSpace(out) != "" {
t.Fatalf("quiet no-emit run wrote stdout:\n%s", out)
}
for _, path := range []string{project.OutDir, project.BuildInfo, project.Manifest} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("analysis-only run published %s: %v", path, err)
}
}
})
}
}
Loading
Loading