a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
319 lines
9.8 KiB
Go
319 lines
9.8 KiB
Go
package languages
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// JCL (MVS/z-OS Job Control Language) extraction is line-based: every
|
|
// statement starts in column 1 with `//`. We model the job stream as a
|
|
// small call graph:
|
|
//
|
|
// //JOBNAME JOB ... -> KindFunction (the job)
|
|
// //STEPNAME EXEC PGM=PROGRAM -> KindMethod (a job step) + EdgeCalls
|
|
// //STEPNAME EXEC procname -> KindMethod (cataloged-proc step) + EdgeCalls
|
|
// //DDNAME DD DSN=dataset -> KindVariable (a DD) + EdgeReferences
|
|
//
|
|
// Continuation lines (operand field ending in a comma, next line `//` plus
|
|
// spaces) are folded into the owning statement on a best-effort basis so a
|
|
// PGM= or DSN= that spills to the next line is still captured.
|
|
var (
|
|
// A JCL name-bearing statement: `//NAME OP rest`. NAME is the label,
|
|
// OP is the operation (JOB / EXEC / DD / etc).
|
|
jclStmtRe = regexp.MustCompile(`^//([A-Za-z$#@][A-Za-z0-9$#@]*)\s+(\S+)\s*(.*)$`)
|
|
// PGM=program operand on an EXEC statement.
|
|
jclPgmRe = regexp.MustCompile(`(?i)\bPGM=([A-Za-z$#@][A-Za-z0-9$#@.]*)`)
|
|
// PROC=procname operand (explicit form of EXEC procname).
|
|
jclProcRe = regexp.MustCompile(`(?i)\bPROC=([A-Za-z$#@][A-Za-z0-9$#@.]*)`)
|
|
// DSN= / DSNAME= dataset operand on a DD statement.
|
|
jclDSNRe = regexp.MustCompile(`(?i)\bDSN(?:AME)?=([A-Za-z0-9$#@.()'+\-]+)`)
|
|
)
|
|
|
|
// JCLExtractor extracts MVS/z-OS Job Control Language job streams.
|
|
type JCLExtractor struct{}
|
|
|
|
func NewJCLExtractor() *JCLExtractor { return &JCLExtractor{} }
|
|
|
|
func (e *JCLExtractor) Language() string { return "jcl" }
|
|
func (e *JCLExtractor) Extensions() []string { return []string{".jcl", ".job"} }
|
|
|
|
func (e *JCLExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
|
rawLines := strings.Split(string(src), "\n")
|
|
result := &parser.ExtractionResult{}
|
|
|
|
fileNode := &graph.Node{
|
|
ID: filePath, Kind: graph.KindFile, Name: filePath,
|
|
FilePath: filePath, StartLine: 1, EndLine: len(rawLines),
|
|
Language: "jcl",
|
|
}
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
stmts := foldJCLStatements(rawLines)
|
|
|
|
seen := make(map[string]bool)
|
|
addNode := func(id string, kind graph.NodeKind, name string, line int, meta map[string]any) {
|
|
if seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: kind, Name: name,
|
|
FilePath: filePath, StartLine: line, EndLine: line,
|
|
Language: "jcl", Meta: meta,
|
|
})
|
|
}
|
|
addEdge := func(from, to string, kind graph.EdgeKind, line int) {
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: from, To: to, Kind: kind, FilePath: filePath, Line: line,
|
|
})
|
|
}
|
|
|
|
jobID := "" // the most recent JOB node ID (parent for steps)
|
|
curStepID := "" // the most recent EXEC step node ID (parent for DDs)
|
|
curStepName := "" // step name, used to namespace DD node IDs
|
|
|
|
for _, st := range stmts {
|
|
m := jclStmtRe.FindStringSubmatch(st.text)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
name, op, rest := m[1], strings.ToUpper(m[2]), m[3]
|
|
switch op {
|
|
case "JOB":
|
|
id := filePath + "::" + name
|
|
addNode(id, graph.KindFunction, name, st.line,
|
|
map[string]any{"jcl_kind": "job"})
|
|
addEdge(fileNode.ID, id, graph.EdgeDefines, st.line)
|
|
jobID = id
|
|
curStepID = ""
|
|
curStepName = ""
|
|
|
|
case "EXEC":
|
|
id := filePath + "::" + name
|
|
meta := map[string]any{"jcl_kind": "step"}
|
|
parent := fileNode.ID
|
|
if jobID != "" {
|
|
parent = jobID
|
|
}
|
|
if pm := jclPgmRe.FindStringSubmatch(rest); pm != nil {
|
|
pgm := pm[1]
|
|
meta["pgm"] = pgm
|
|
addNode(id, graph.KindMethod, name, st.line, meta)
|
|
addEdge(parent, id, graph.EdgeDefines, st.line)
|
|
addEdge(id, "unresolved::program::"+pgm, graph.EdgeCalls, st.line)
|
|
} else {
|
|
// EXEC procname (cataloged procedure) — either bare
|
|
// (first operand token) or the explicit PROC= form.
|
|
proc := ""
|
|
if pm := jclProcRe.FindStringSubmatch(rest); pm != nil {
|
|
proc = pm[1]
|
|
} else {
|
|
proc = firstOperandToken(rest)
|
|
}
|
|
if proc != "" {
|
|
meta["proc"] = proc
|
|
}
|
|
addNode(id, graph.KindMethod, name, st.line, meta)
|
|
addEdge(parent, id, graph.EdgeDefines, st.line)
|
|
if proc != "" {
|
|
addEdge(id, "unresolved::proc::"+proc, graph.EdgeCalls, st.line)
|
|
}
|
|
}
|
|
curStepID = id
|
|
curStepName = name
|
|
|
|
case "DD":
|
|
// DD node id is namespaced by the enclosing step when known,
|
|
// so duplicate DD names across steps don't collide.
|
|
ddID := filePath + "::DD:" + name
|
|
if curStepName != "" {
|
|
ddID = filePath + "::" + curStepName + "." + name
|
|
}
|
|
meta := map[string]any{"jcl_kind": "dd"}
|
|
dsn := ""
|
|
if dm := jclDSNRe.FindStringSubmatch(rest); dm != nil {
|
|
dsn = normalizeDSN(dm[1])
|
|
}
|
|
if dsn != "" {
|
|
meta["dsn"] = dsn
|
|
}
|
|
addNode(ddID, graph.KindVariable, name, st.line, meta)
|
|
parent := fileNode.ID
|
|
if curStepID != "" {
|
|
parent = curStepID
|
|
}
|
|
addEdge(parent, ddID, graph.EdgeDefines, st.line)
|
|
// DUMMY / SYSOUT-only DDs carry no dataset — emit no
|
|
// dataset reference for them.
|
|
if dsn != "" {
|
|
addEdge(parent, "unresolved::dataset::"+dsn, graph.EdgeReferences, st.line)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// jclStatement is a logical JCL statement after continuation folding.
|
|
type jclStatement struct {
|
|
text string // folded statement text (leading `//` + operands)
|
|
line int // 1-based line of the statement's first physical line
|
|
}
|
|
|
|
// foldJCLStatements collapses continuation lines into single logical
|
|
// statements. A statement continues when its operand field ends in a
|
|
// comma and the following line is `//` followed by whitespace (the
|
|
// continuation marker). Comment lines (`//*`) and the in-stream data
|
|
// delimiter (`/*`, `//`) are dropped. Best-effort: it folds the operand
|
|
// of the next continuation line onto the current statement so a PGM= /
|
|
// DSN= spilling to the next line is captured.
|
|
func foldJCLStatements(lines []string) []jclStatement {
|
|
var out []jclStatement
|
|
for i := 0; i < len(lines); i++ {
|
|
line := strings.TrimRight(lines[i], "\r")
|
|
// Comment line: `//*...`
|
|
if strings.HasPrefix(line, "//*") {
|
|
continue
|
|
}
|
|
// In-stream data delimiter `/*` or null statement `//`.
|
|
if line == "//" || strings.HasPrefix(line, "/*") {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(line, "//") {
|
|
continue // in-stream data or sequence noise
|
|
}
|
|
// A continuation marker (`//` + spaces) only makes sense after a
|
|
// statement; a leading one with no owner is skipped.
|
|
afterSlashes := line[2:]
|
|
if strings.TrimSpace(afterSlashes) == "" {
|
|
continue
|
|
}
|
|
if afterSlashes[0] == ' ' || afterSlashes[0] == '\t' {
|
|
// Orphan continuation (no preceding statement) — ignore.
|
|
continue
|
|
}
|
|
|
|
startLine := i + 1
|
|
// Drop the inline comment that JCL allows after a blank past the
|
|
// operand field. We keep it simple: strip everything after the
|
|
// first run of spaces that follows a space-terminated operand.
|
|
text := stripJCLComment(line)
|
|
// Fold continuation lines.
|
|
for endsWithContinuation(text) && i+1 < len(lines) {
|
|
next := strings.TrimRight(lines[i+1], "\r")
|
|
if !strings.HasPrefix(next, "//") {
|
|
break
|
|
}
|
|
cont := next[2:]
|
|
if cont == "" || (cont[0] != ' ' && cont[0] != '\t') {
|
|
break // next line is a new statement, not a continuation
|
|
}
|
|
operand := strings.TrimSpace(cont)
|
|
// Comment-only continuation.
|
|
text = strings.TrimRight(text, " ") + stripJCLComment(operand)
|
|
i++
|
|
}
|
|
out = append(out, jclStatement{text: text, line: startLine})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// endsWithContinuation reports whether a JCL operand field continues on
|
|
// the next line — true when the (comment-stripped) text ends in a comma.
|
|
func endsWithContinuation(text string) bool {
|
|
t := strings.TrimRight(text, " \t")
|
|
return strings.HasSuffix(t, ",")
|
|
}
|
|
|
|
// stripJCLComment removes a trailing free-form comment from a JCL line.
|
|
// JCL comments begin after the operand field at the first space that is
|
|
// not inside quotes or parentheses. This is best-effort and conservative:
|
|
// it only strips when a space is found outside quotes/parens past the
|
|
// operation field.
|
|
func stripJCLComment(line string) string {
|
|
inQuote := false
|
|
depth := 0
|
|
// Locate the start of the operand field (the 3rd whitespace-delimited
|
|
// token: after the label and the operation). The comment, if any,
|
|
// begins at the first unquoted/unparenthesised space *within* the
|
|
// operand field, so we must not start scanning before it.
|
|
tokens := 0
|
|
prevSpace := true
|
|
operandStart := -1
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
if c == ' ' || c == '\t' {
|
|
prevSpace = true
|
|
} else {
|
|
if prevSpace {
|
|
tokens++
|
|
if tokens == 3 {
|
|
operandStart = i
|
|
break
|
|
}
|
|
}
|
|
prevSpace = false
|
|
}
|
|
}
|
|
if operandStart < 0 {
|
|
return line // fewer than three tokens — no operand field to scan
|
|
}
|
|
for i := operandStart; i < len(line); i++ {
|
|
c := line[i]
|
|
switch c {
|
|
case '\'':
|
|
inQuote = !inQuote
|
|
case '(':
|
|
if !inQuote {
|
|
depth++
|
|
}
|
|
case ')':
|
|
if !inQuote {
|
|
depth--
|
|
}
|
|
case ' ', '\t':
|
|
if !inQuote && depth == 0 {
|
|
return line[:i]
|
|
}
|
|
}
|
|
}
|
|
return line
|
|
}
|
|
|
|
// firstOperandToken returns the first comma-or-space-delimited operand
|
|
// token of a JCL operand field (used for `EXEC procname`).
|
|
func firstOperandToken(rest string) string {
|
|
rest = strings.TrimSpace(rest)
|
|
if rest == "" {
|
|
return ""
|
|
}
|
|
end := len(rest)
|
|
for i, c := range rest {
|
|
if c == ',' || c == ' ' || c == '\t' || c == '(' {
|
|
end = i
|
|
break
|
|
}
|
|
}
|
|
tok := rest[:end]
|
|
// A bare token containing `=` is a keyword operand, not a proc name.
|
|
if strings.Contains(tok, "=") {
|
|
return ""
|
|
}
|
|
return tok
|
|
}
|
|
|
|
// normalizeDSN trims a DSN operand to the bare dataset name: it strips
|
|
// surrounding quotes and any member/GDG suffix in parentheses.
|
|
func normalizeDSN(dsn string) string {
|
|
dsn = strings.Trim(dsn, "'")
|
|
if idx := strings.IndexByte(dsn, '('); idx >= 0 {
|
|
dsn = dsn[:idx]
|
|
}
|
|
return strings.TrimRight(dsn, ",")
|
|
}
|
|
|
|
var _ parser.Extractor = (*JCLExtractor)(nil)
|