diff --git a/core/plugin.awk b/core/plugin.awk index 821dee50..0c688685 100644 --- a/core/plugin.awk +++ b/core/plugin.awk @@ -17,15 +17,30 @@ # config_keys を満たさなかった場合 PLUGIN_REGISTER_ERROR=1 を立てる # (起動時 main loop が exit 1 で判定する) -function plugin_discover( cmd, pname, func_name, meta) { - cmd = "ls plugins 2>/dev/null" - while ((cmd | getline pname) > 0) { +function plugin_discover( cmd, pname, func_name, meta, root, disabled, dirs, n, i, dir) { + if (ENVIRON["HAWK_PLUGIN_DIRS"] != "") { + n = split(ENVIRON["HAWK_PLUGIN_DIRS"], dirs, "\n") + } else { + root = (ENVIRON["HAWK_LIB"] != "" ? ENVIRON["HAWK_LIB"] : ".") "/plugins" + cmd = "ls " _shellquote(root) " 2>/dev/null" + while ((cmd | getline pname) > 0) { + if (pname == "" || pname ~ /^\./) continue + dirs[++n] = root "/" pname + } + close(cmd) + } + for (i = 1; i <= n; i++) { + dir = dirs[i] + sub(/\/+$/, "", dir) + pname = dir + sub(/^.*\//, "", pname) if (pname == "" || pname ~ /^\./) continue - if ((getline _ < ("plugins/" pname "/.disabled")) >= 0) { - close("plugins/" pname "/.disabled") + disabled = dir "/.disabled" + if ((getline _ < disabled) >= 0) { + close(disabled) continue } - close("plugins/" pname "/.disabled") + close(disabled) func_name = "plugin_" pname "_manifest" delete meta @@ -36,7 +51,6 @@ function plugin_discover( cmd, pname, func_name, meta) { @func_name(meta) plugin_register(pname, meta) } - close(cmd) } function plugin_register(pname, meta, keys, n, j, hooks, m, hook_name, k) { diff --git a/docs/ja/plugins.ja.md b/docs/ja/plugins.ja.md index 11abd632..8f9e9c2f 100644 --- a/docs/ja/plugins.ja.md +++ b/docs/ja/plugins.ja.md @@ -4,6 +4,8 @@ `plugins//` にプラグインディレクトリを配置すると、H-awk が起動時に自動発見してロードします。 +H-awk は `$HAWK_LIB/plugins`(framework 側)と `./plugins`(アプリの作業ディレクトリ)の両方からプラグインをロードします。同名プラグインが両方にある場合はアプリ側が優先され、アプリ側に `.disabled` があれば同名の framework 側プラグインも無効になります。 + ```awk # plugins/logger/manifest.awk function plugin_logger_manifest(meta) { diff --git a/docs/plugins.md b/docs/plugins.md index 08b08a59..65263f44 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -4,6 +4,8 @@ Drop a plugin directory into `plugins//`. H-awk auto-discovers and loads plugins at startup. +H-awk loads plugins from both `$HAWK_LIB/plugins` (the framework) and `./plugins` (the app working directory). If both contain the same plugin name, the app plugin wins; an app-side `.disabled` also shadows and disables the framework plugin of that name. + ```awk # plugins/logger/manifest.awk function plugin_logger_manifest(meta) { diff --git a/dsl/check.awk b/dsl/check.awk index d1e74c61..d26d9f14 100644 --- a/dsl/check.awk +++ b/dsl/check.awk @@ -1887,11 +1887,13 @@ function v2_prescan_expr_type(id, kind, name, inner, ret_sig, typearg) { function v2_check() { v2_init_builtins() + v2_merge_shared() VARIANTS["Result"] = "ok" SUBSEP "ng" VARIANTS["Option"] = "some" SUBSEP "none" v2_collect_raw_funcs() v2_collect(1) + v2_check_shared_consistency() v2_infer_unannotated_returns(1) v2_check_alias_cycles() v2_check_record_return_type(1) @@ -1906,6 +1908,37 @@ function v2_check() { v2_check_mut(1) } +function v2_check_shared_consistency( k) { + for (k in V2_SHARED_ALIAS) + if (ALIAS[k] != V2_SHARED_ALIAS[k]) v2_diag(1, 1, "conflicting shared type alias: " k) + for (k in V2_SHARED_SIG) + if (SIG[k] != V2_SHARED_SIG[k]) v2_diag(1, 1, "conflicting shared function signature") + for (k in V2_SHARED_RECORD_FIELDS) + if (V2_RECORD_FIELDS[k] != V2_SHARED_RECORD_FIELDS[k]) v2_diag(1, 1, "conflicting shared record field") +} + +function v2_merge_shared( k, p) { + for (k in V2_SHARED_ALIAS) { + if ((k in ALIAS) && ALIAS[k] != V2_SHARED_ALIAS[k]) + v2_diag(1, 1, "conflicting shared type alias: " k) + ALIAS[k] = V2_SHARED_ALIAS[k] + } + for (k in V2_SHARED_SIG) { + split(k, p, SUBSEP) + if ((k in SIG) && SIG[k] != V2_SHARED_SIG[k]) + v2_diag(1, 1, "conflicting shared function signature: " p[1]) + SIG[k] = V2_SHARED_SIG[k] + } + for (k in V2_SHARED_RECORD_TYPE) V2_RECORD_TYPE[k] = 1 + for (k in V2_SHARED_RECORD_FIELDS) { + split(k, p, SUBSEP) + if ((k in V2_RECORD_FIELDS) && V2_RECORD_FIELDS[k] != V2_SHARED_RECORD_FIELDS[k]) + v2_diag(1, 1, "conflicting shared record field: " p[1] "." p[2]) + V2_RECORD_FIELDS[k] = V2_SHARED_RECORD_FIELDS[k] + } + for (k in V2_SHARED_RAW_FUNC) V2_RAW_FUNC[k] = 1 +} + # LET/LETQ の直接の初期化式(最後の子)だけが空コレクションリテラル # []/{} を受け付けられる(v2_e_let はこの位置しかハンドルしない)。 # v2_check_bare_collection_lit より前に呼び、当該 id を許可集合に登録する。 diff --git a/dsl/desugar.awk b/dsl/desugar.awk index b7e427cd..cfb9d3ca 100644 --- a/dsl/desugar.awk +++ b/dsl/desugar.awk @@ -14,6 +14,7 @@ BEGIN { V2_SRC = (ARGC > 1) ? ARGV[1] : "/dev/stdin" V2_ERRORS = 0 + v2_merge_shared_pre_rpn() v2_lex(V2_SRC) if (V2_DUMP == "lex") { v2_dump_lex(); exit(V2_ERRORS ? 1 : 0) } v2_rpn() @@ -27,6 +28,17 @@ BEGIN { exit(V2_ERRORS ? 1 : 0) } +function v2_merge_shared_pre_rpn( k, p) { + for (k in V2_SHARED_ALIAS) ALIAS[k] = V2_SHARED_ALIAS[k] + for (k in V2_SHARED_SIG) SIG[k] = V2_SHARED_SIG[k] + for (k in V2_SHARED_RECORD_TYPE) V2_RECORD_TYPE[k] = 1 + for (k in V2_SHARED_RECORD_FIELDS) { + split(k, p, SUBSEP) + V2_RECORD_FIELDS[p[1], p[2]] = V2_SHARED_RECORD_FIELDS[k] + } + for (k in V2_SHARED_RAW_FUNC) V2_RAW_FUNC[k] = 1 +} + function v2_dump_lex( i) { for (i = 1; i <= TOK["n"]; i++) printf "%d\t%s\t%d\t%d\t%s\n", i, TOK[i,"kind"], TOK[i,"line"], TOK[i,"col"], TOK[i,"text"] } function v2_dump_rpn( i) { for (i = 1; i <= RPN["n"]; i++) printf "%d\t%s\t%d\t%s\t%s\n", i, RPN[i,"kind"], RPN[i,"line"], RPN[i,"val"], ((RPN[i,"kind"]=="CALL") ? RPN[i,"arity"] : "") } function v2_dump_ast( id, k, cs) { for (id = 1; id <= V2_NAST; id++) { cs = ""; for (k = 1; k <= AST[id,"nc"]; k++) cs = cs ((k>1)?",":"") AST[id,"c" k]; printf "%d\t%s\t%d\t%d\t%s\t%s\n", id, AST[id,"kind"], AST[id,"line"], AST[id,"nc"], cs, AST[id,"text"] } } diff --git a/dsl/index.awk b/dsl/index.awk new file mode 100644 index 00000000..8874be36 --- /dev/null +++ b/dsl/index.awk @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: MIT +@include "dsl/util.awk" +@include "dsl/lex.awk" +@include "dsl/rpn.awk" +@include "dsl/parse.awk" +@include "dsl/check.awk" + +function q(s) { + gsub(/\\/, "\\\\", s) + gsub(/"/, "\\\"", s) + return "\"" s "\"" +} + +function emit_shared(out, k, p, n, v) { + print "BEGIN {" > out + for (k in V2_RECORD_TYPE) + printf " V2_SHARED_RECORD_TYPE[%s] = 1\n", q(k) > out + for (k in V2_RECORD_FIELDS) { + split(k, p, SUBSEP) + printf " V2_SHARED_RECORD_FIELDS[%s, %s] = %s\n", q(p[1]), q(p[2]), q(V2_RECORD_FIELDS[k]) > out + } + for (k in ALIAS) + printf " V2_SHARED_ALIAS[%s] = %s\n", q(k), q(ALIAS[k]) > out + for (k in SIG) { + split(k, p, SUBSEP) + printf " V2_SHARED_SIG[%s, %s] = %s\n", q(p[1]), q(p[2]), q(SIG[k]) > out + } + for (k in V2_RAW_FUNC) + printf " V2_SHARED_RAW_FUNC[%s] = 1\n", q(k) > out + print "}" > out +} + +BEGIN { + if (V2_INDEX_LIST == "") exit 1 + V2_INDEX_MODE = 1 + n = split(V2_INDEX_LIST, files, SUBSEP) + for (i = 1; i <= n; i++) { + if (files[i] == "") continue + delete V2_INDEX_RUN_FIELDS + delete V2_INDEX_RUN_RECORDS + delete V2_INDEX_OLD_FIELDS + for (k in V2_INDEX_RECORD_FIELDS) V2_INDEX_OLD_FIELDS[k] = V2_INDEX_RECORD_FIELDS[k] + delete V2_INDEX_OLD_RECORDS + for (k in V2_INDEX_RECORD_NAMES) V2_INDEX_OLD_RECORDS[k] = 1 + v2_lex(files[i]) + v2_rpn() + for (k in V2_RECORD_FIELDS) { + if ((k in V2_INDEX_RECORD_FIELDS) && V2_INDEX_RECORD_FIELDS[k] != V2_RECORD_FIELDS[k]) { + print "[hawk-libs] index failed: conflicting record field definition" > "/dev/stderr" + exit 1 + } + V2_INDEX_RECORD_FIELDS[k] = V2_RECORD_FIELDS[k] + } + for (k in V2_INDEX_OLD_FIELDS) { + split(k, p, SUBSEP) + if ((p[1] in V2_INDEX_RUN_RECORDS) && !(k in V2_INDEX_RUN_FIELDS)) { + print "[hawk-libs] index failed: conflicting record field definition" > "/dev/stderr" + exit 1 + } + } + for (k in V2_INDEX_RUN_FIELDS) { + split(k, p, SUBSEP) + if ((p[1] in V2_INDEX_OLD_RECORDS) && !(k in V2_INDEX_OLD_FIELDS)) { + print "[hawk-libs] index failed: conflicting record field definition" > "/dev/stderr" + exit 1 + } + } + for (k in V2_INDEX_RUN_RECORDS) { + if (k in V2_INDEX_ALIAS) { + print "[hawk-libs] index failed: type name is both record and alias: " k > "/dev/stderr" + exit 1 + } + V2_INDEX_RECORD_NAMES[k] = 1 + } + v2_parse() + v2_collect(1) + v2_collect_raw_funcs() + for (k in ALIAS) { + if (k in V2_INDEX_RECORD_NAMES) { + print "[hawk-libs] index failed: type name is both record and alias: " k > "/dev/stderr" + exit 1 + } + if ((k in V2_INDEX_ALIAS) && V2_INDEX_ALIAS[k] != ALIAS[k]) { + print "[hawk-libs] index failed: conflicting type alias definition: " k > "/dev/stderr" + exit 1 + } + V2_INDEX_ALIAS[k] = ALIAS[k] + } + for (k in SIG) { + if ((k in V2_INDEX_SIG) && V2_INDEX_SIG[k] != SIG[k]) { + print "[hawk-libs] index failed: conflicting function signature: " k > "/dev/stderr" + exit 1 + } + V2_INDEX_SIG[k] = SIG[k] + } + } + emit_shared(V2_INDEX_OUT) + exit 0 +} diff --git a/dsl/lex.awk b/dsl/lex.awk index 102e256b..2b0a54b1 100644 --- a/dsl/lex.awk +++ b/dsl/lex.awk @@ -63,7 +63,7 @@ function v2_strip_str_comment(line, i, ch, out, instr) { } # DSL 構文を含む行か判定する述語 -# type NAME = ... 宣言をファイル全体から事前収集し、型注釈検出(v2_is_dsl の +# type NAME = ... 宣言を共有 index とファイル全体から事前収集し、型注釈検出(v2_is_dsl の # `: TYPE` 判定)に使う代替パターンを組み立てる(CE)。組込み型集合に無い # ユーザー定義エイリアスだけが注釈に使われている関数(`type Status = Int|Str` # + `function f(x: Status) {...}`)は、この事前収集なしでは検出できず素の awk @@ -72,8 +72,11 @@ function v2_strip_str_comment(line, i, ch, out, instr) { # 誤検出リスク: エイリアス名が偶然コメント・文字列中の識別子と一致するケースは # 元々の固定型リストと同じ理由で許容する(v2_strip_str_comment 済みの行に対して # のみ判定するため文字列内は対象外)。 -function v2_collect_type_aliases(nlines, i, s, m, names, n) { +function v2_collect_type_aliases(nlines, i, s, m, names, n, k) { names = ""; n = 0 + for (k in V2_SHARED_ALIAS) { + if (!(k in V2_DSL_ALIAS)) { V2_DSL_ALIAS[k] = 1; names = names (n++ ? "|" : "") k } + } for (i = 1; i <= nlines; i++) { s = v2_strip_str_comment(V2_RAWLINE[i]) if (match(s, /(^|[^[:alnum:]_])type[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=/, m)) { @@ -83,14 +86,18 @@ function v2_collect_type_aliases(nlines, i, s, m, names, n) { V2_ALIAS_TYPEANN_RE = names } -# 戻り値型注釈(`-> RETTYPE`)を持つユーザー定義関数名をファイル全体から +# 戻り値型注釈(`-> RETTYPE`)を持つユーザー定義関数名を共有 index とファイル全体から # 事前収集する(review EQ)。DSL マーカーを一切持たない関数本体 # (`function handler() { normalize(123) }`)でも、本体が既知の注釈付き # 関数を呼び出しているだけなら v1 は引数型検査を行う。v2 は関数ヘッダに # DSL 構文が無いと本体全体を素通し(RAWLINE)扱いにするため、この呼び出し # だけが v2_check_calls の CALL 検査を経由せず引数型不一致を見逃していた。 -function v2_collect_annotated_funcs(nlines, i, s, m, names, n) { +function v2_collect_annotated_funcs(nlines, i, s, m, names, n, k, p) { names = ""; n = 0 + for (k in V2_SHARED_SIG) { + split(k, p, SUBSEP) + if (!(p[1] in V2_DSL_FUNCNAME)) { V2_DSL_FUNCNAME[p[1]] = 1; names = names (n++ ? "|" : "") p[1] } + } for (i = 1; i <= nlines; i++) { s = v2_strip_str_comment(V2_RAWLINE[i]) # 戻り値注釈 (-> RETTYPE) を持つ関数 @@ -235,6 +242,11 @@ function v2_mark_unannotated_func(nlines, i, j, k, depth, stripped, tmp, n_op # トークン化する(BQ)。{ を含む行を処理したら解除する。 function v2_lex(src, line, lineno, in_when, in_block, nlines, tok_start, k, \ pending_brace, header_stripped, force_line) { + delete TOK + delete PASS + delete V2_RAWLINE + delete V2_LINE_TEXT + delete V2_FORCE_DSL V2_NLINES = 0 TOK["n"] = 0 in_when = 0 diff --git a/dsl/rpn.awk b/dsl/rpn.awk index 1bb9bb1e..35c7781b 100644 --- a/dsl/rpn.awk +++ b/dsl/rpn.awk @@ -857,6 +857,7 @@ function v2_rpn_typedecl(i, line, name, j, typestart, typetext) { if (j <= TOK["n"] && TOK[j,"kind"] == "OP" && TOK[j,"text"] == "=" && \ TOK[j+1,"kind"] == "LBRACE") { V2_RECORD_TYPE[name] = 1 + if (V2_INDEX_MODE) V2_INDEX_RUN_RECORDS[name] = 1 return v2_rpn_record_fields(name, j + 1) } @@ -899,6 +900,7 @@ function v2_rpn_record_fields(typename, open_pos, j, fname, typestart, typete for (k = typestart; k < j; k++) typetext = typetext ((TOK[k,"kind"] == "COMMA") ? ", " : TOK[k,"text"]) V2_RECORD_FIELDS[typename, fname] = typetext + if (V2_INDEX_MODE) V2_INDEX_RUN_FIELDS[typename, fname] = typetext if (TOK[j,"kind"] == "COMMA") j++ # inline 形の区切り } # v1 は record 宣言ブロック全体と直後の空行 1 行をまとめてスキップし、 @@ -1490,6 +1492,7 @@ function v2_rpn( i) { # ファイル先頭から順に埋める。プログラム全体で 1 つの表(関数スコープでは # クリアしない)。 delete V2_RPN_ALIAS + for (i in V2_SHARED_ALIAS) V2_RPN_ALIAS[i] = V2_SHARED_ALIAS[i] v2_rpn_prescan_records() diff --git a/libexec/hawk-check b/libexec/hawk-check index 92ff7510..7682ec00 100755 --- a/libexec/hawk-check +++ b/libexec/hawk-check @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e -HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" +export HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" HAWK_LIBEXEC="${HAWK_LIBEXEC:-$(cd "$(dirname "$0")" && pwd)}" LIBS="${HAWK_LIBEXEC}/hawk-libs" diff --git a/libexec/hawk-emit b/libexec/hawk-emit index 240f04d2..2322576b 100755 --- a/libexec/hawk-emit +++ b/libexec/hawk-emit @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e -HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" +export HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" HAWK_LIBEXEC="${HAWK_LIBEXEC:-$(cd "$(dirname "$0")" && pwd)}" LIBS="${HAWK_LIBEXEC}/hawk-libs" diff --git a/libexec/hawk-libs b/libexec/hawk-libs index b702aebb..2f96ad64 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -5,17 +5,43 @@ HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" subcmd="${1:-}" shift +_plugin_dirs() { + local app_root fw_root d name same_root=0 + app_root="$(pwd -P)/plugins" + fw_root="$(cd "$HAWK_LIB" && pwd -P)/plugins" + if [[ "$fw_root" == "$app_root" ]] || { [[ -d "$fw_root" && -d "$app_root" ]] && [[ "$fw_root" -ef "$app_root" ]]; }; then + same_root=1 + fi + if [[ "$same_root" -eq 0 ]]; then + for d in "$fw_root"/*/; do + [[ -d "$d" ]] || continue + d="${d%/}" + name="${d##*/}" + [[ -d "$app_root/$name" ]] && continue + [[ -f "$d/.disabled" ]] && continue + printf '%s\n' "$d" + done + fi + for d in "$app_root"/*/; do + [[ -d "$d" ]] || continue + d="${d%/}" + [[ -f "$d/.disabled" ]] && continue + printf '%s\n' "$d" + done +} + case "$subcmd" in plugins) - for d in plugins/*/; do - [[ -d "$d" ]] || continue - [[ -f "${d}.disabled" ]] && continue - name=$(basename "$d") - manifest="${d}manifest.awk" - impl="${d}${name}.awk" + while IFS= read -r d; do + name="${d##*/}" + manifest="$d/manifest.awk" + impl="$d/$name.awk" [[ -f "$manifest" ]] && printf '%s\n' "$manifest" [[ -f "$impl" ]] && printf '%s\n' "$impl" - done + done < <(_plugin_dirs) + ;; + plugin-dirs) + _plugin_dirs ;; libs) libs_args="" @@ -26,7 +52,7 @@ case "$subcmd" in *) so_ext=so ;; esac if [[ -z "$HAWK_NO_LIBS" ]]; then - for d in libs/*/; do + for d in "$HAWK_LIB"/libs/*/; do [[ -d "$d" ]] || continue name=$(basename "$d") [[ "$name" = "_common" ]] && continue @@ -46,6 +72,34 @@ case "$subcmd" in printf 'LIBS_VARS=%s\n' "$(printf '%q' "$libs_vars")" printf 'HAS_NET=%d\n' "$has_net" ;; + runtime-awk) + _runtime_seen=$'\n' + _runtime_include_re='^[[:space:]]*@include[[:space:]]+"([^"]+)"' + _runtime_namespace_re='^[[:space:]]*@namespace[[:space:]]+"([^"]+)"' + _emit_runtime_awk() { + local file="$1" namespace="$2" line inc + case "$_runtime_seen" in *$'\n'"$file"$'\n'*) return 0 ;; esac + _runtime_seen="${_runtime_seen}${file}"$'\n' + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ $_runtime_namespace_re ]]; then + namespace="${BASH_REMATCH[1]}" + elif [[ "$line" =~ $_runtime_include_re ]]; then + inc="${BASH_REMATCH[1]}" + if [[ "$inc" != /* ]]; then + if [[ ! -f "$HAWK_LIB/$inc" ]]; then + echo "[hawk-libs] runtime-awk: include not found: $inc" >&2 + return 1 + fi + _emit_runtime_awk "$HAWK_LIB/$inc" "$namespace" + printf '@namespace "%s"\n' "$namespace" + continue + fi + fi + printf '%s\n' "$line" + done < "$file" + } + _emit_runtime_awk "$HAWK_LIB/hawk.awk" awk + ;; desugar) src="${1:-}" if [[ ! -f "$src" ]]; then @@ -53,6 +107,7 @@ case "$subcmd" in exit 1 fi dist="${HAWK_DIST:-dist}" + while [[ "$dist" == */ ]]; do dist="${dist%/}"; done mkdir -p "$dist" # marker の異常 (symlink / 非 regular file / 書込不可) は publish 後の # 作成・更新で失敗し「exit 1 なのに成果物は更新済み」になるため、 @@ -82,27 +137,251 @@ case "$subcmd" in fi src_root="$(cd "$(dirname "$src")" && pwd -P)" dist_phys="$(cd "$dist" && pwd -P)" - _seen=" " - # scratch と publish キューは配列で持つ - # (空白入りパスが word splitting で別引数に化けるのを防ぐ) + cwd_phys="$(pwd -P)" + _marker_owns_output() { + local owner_src="$1" rel="$2" kind="${3:-}" marker="$dist/.hawk-dist" + [[ -f "$marker" ]] || return 1 + if [[ "$kind" == compat ]]; then + # 旧2列行は同じ src_root が flat dist/ を publish した所有記録。 + # namespaced stable は ns/rel なので bare rel に一致せず、empty namespace は + # stable == compat の同一パス。他 src_root も行全体の完全一致で除外される。 + grep -qxF -- "$owner_src"$'\t'"$rel"$'\t'compat "$marker" || grep -qxF -- "$owner_src"$'\t'"$rel" "$marker" + else + grep -qxF -- "$owner_src"$'\t'"$rel" "$marker" + fi + } + case "$src" in + /*) + src_abs="$src" + ;; + *) + src_abs="$cwd_phys/$src" + ;; + esac + _src_resolved="$src_abs" + _src_hops=0 + while [[ -L "$_src_resolved" ]]; do + _src_hops=$((_src_hops + 1)) + if [[ $_src_hops -gt 40 ]]; then + echo "[hawk-libs] desugar failed: symlink loop resolving source: $src" >&2 + exit 1 + fi + _src_target="$(readlink "$_src_resolved")" + if [[ "$_src_target" == /* ]]; then + _src_resolved="$_src_target" + else + _src_resolved="$(dirname "$_src_resolved")/$_src_target" + fi + done + src_abs="$(cd "$(dirname "$_src_resolved")" && pwd -P)/$(basename "$_src_resolved")" + case "$src_abs" in + "$cwd_phys"/*) entry_key="${src_abs#"$cwd_phys"/}" ;; + *) entry_key="external-$(printf '%s' "$src_root" | cksum | awk '{print $1}')/$(basename "$src")" ;; + esac + while [[ "$entry_key" == ./* ]]; do entry_key="${entry_key#./}"; done + while [[ "$entry_key" == */./* ]]; do entry_key="${entry_key/\/\.\//\/}"; done + case "$entry_key" in ../*|*/../*|*" "*) + echo "[hawk-libs] desugar failed: invalid entry path: $entry_key" >&2 + exit 1 + ;; + esac + case "$entry_key" in + .hawk-dist|.hawk-dist/*|.hawk-generations|.hawk-generations/*|current|current/*) + echo "[hawk-libs] desugar failed: reserved entry namespace: $entry_key" >&2 + exit 1 + ;; + esac + if [[ "$entry_key" == */* ]]; then + namespace="${entry_key%/*}" + else + namespace="" + fi + _dist_has_source=0 + if [[ "$dist_phys/" == "$src_root/"* ]]; then + while IFS= read -r _existing; do + [[ "$_existing" == "$dist/.hawk-dist" || "$_existing" == "$dist/.hawk-generations/"* ]] && continue + _existing_rel="${_existing#"$dist"/}" + if _marker_owns_output "$src_root" "$_existing_rel" || _marker_owns_output "$src_root" "$_existing_rel" compat; then + continue + fi + _dist_has_source=1 + break + done < <(find "$dist" -type f -print) + fi + if [[ "$src_abs" == "$dist_phys" || "$src_abs" == "$dist_phys/"* || $_dist_has_source -eq 1 ]]; then + echo "[hawk-libs] desugar failed: refusing to overwrite source tree (dist and source trees overlap)" >&2 + exit 1 + fi + _scratch_arr=() _cleanup_scratches() { local f for f in "${_scratch_arr[@]}"; do rm -f "$f"; done } trap _cleanup_scratches EXIT + + _replace_symlink_atomically() { + local tmp="$1" link="$2" + if mv -f -h "$tmp" "$link" 2>/dev/null && [[ ! -e "$tmp" && ! -L "$tmp" ]]; then + return 0 + fi + if mv -f -T "$tmp" "$link"; then return 0; fi + echo "[hawk-libs] desugar failed: no portable symlink replacement command (mv -h or mv -T)" >&2 + exit 1 + } + + _refresh_output_link() { + local link="$1" target="$2" owner_src="$3" rel="$4" kind="${5:-}" actual tmp + if [[ -d "$link" && ! -L "$link" ]]; then + echo "[hawk-libs] desugar failed: stable output is not a symlink: $link" >&2 + exit 1 + fi + if [[ -e "$link" && ! -L "$link" ]] && ! _marker_owns_output "$owner_src" "$rel" "$kind"; then + echo "[hawk-libs] desugar failed: stable output is not a symlink: $link" >&2 + exit 1 + fi + if [[ -L "$link" ]]; then + actual="$(readlink "$link")" + [[ "$actual" == "$target" ]] && return 0 + fi + tmp="$(mktemp "$(dirname "$link")/.hawk-link.XXXXXX")" + _scratch_arr+=("$tmp") + rm -f "$tmp" + ln -s "$target" "$tmp" + _replace_symlink_atomically "$tmp" "$link" + } + + _prepare_dist_parent() { + local parent="$1" rel probe part parent_phys + probe="$dist" + if [[ "$parent" == "$dist"/* ]]; then + rel="${parent#"$dist"/}" + while [[ -n "$rel" ]]; do + if [[ "$rel" == */* ]]; then + part="${rel%%/*}" + rel="${rel#*/}" + else + part="$rel" + rel= + fi + [[ -n "$part" ]] || continue + probe="$probe/$part" + if [[ -L "$probe" ]]; then + echo "[hawk-libs] desugar failed: output directory escapes dist via symlink: $probe" >&2 + exit 1 + fi + [[ -e "$probe" ]] || break + done + elif [[ "$parent" != "$dist" ]]; then + echo "[hawk-libs] desugar failed: output directory escapes dist: $parent" >&2 + exit 1 + fi + mkdir -p "$parent" + parent_phys="$(cd "$parent" && pwd -P)" + if [[ "$parent_phys/" != "$dist_phys/"* ]]; then + echo "[hawk-libs] desugar failed: output directory escapes dist via symlink: $parent_phys" >&2 + exit 1 + fi + } + + _prepare_dist_parent "$dist/.hawk-generations" + stage_root="$(mktemp -d "$dist/.hawk-generations/gen.XXXXXX")" + if [[ -e "$dist/current" || -L "$dist/current" ]]; then + if [[ ! -L "$dist/current" ]]; then + echo "[hawk-libs] desugar failed: $dist/current is not a symlink" >&2 + exit 1 + fi + previous_rel="$(readlink "$dist/current")" + case "$previous_rel" in + .hawk-generations/gen.*/*|/*|../*|*/../*|"") + echo "[hawk-libs] desugar failed: invalid current generation: $previous_rel" >&2 + exit 1 + ;; + .hawk-generations/gen.*) ;; + *) + echo "[hawk-libs] desugar failed: invalid current generation: $previous_rel" >&2 + exit 1 + ;; + esac + previous_root="$dist/$previous_rel" + if [[ -L "$previous_root" || ! -d "$previous_root" ]]; then + echo "[hawk-libs] desugar failed: current generation not found: $previous_root" >&2 + exit 1 + fi + previous_list="$(mktemp "$dist/.hawk-carry.XXXXXX")" + _scratch_arr+=("$previous_list") + if ! find "$previous_root" -type f -print0 > "$previous_list"; then + echo "[hawk-libs] desugar failed: cannot list current generation: $previous_root" >&2 + exit 1 + fi + while IFS= read -r -d '' previous_file; do + previous_file_rel="${previous_file#"$previous_root"/}" + carried_file="$stage_root/$previous_file_rel" + _prepare_dist_parent "$(dirname "$carried_file")" + ln "$previous_file" "$carried_file" + done < "$previous_list" + rm -f "$previous_list" + fi + if [[ "$dist" = /* ]]; then runtime_root="$dist/current"; else runtime_root="$dist_phys/current"; fi + runtime_ns="$runtime_root" + [[ -n "$namespace" ]] && runtime_ns="$runtime_ns/$namespace" + _seen=" " _pub_tmp=() _pub_out=() _pub_rel=() + _compat_skip=() + _compat_is_skipped() { + local rel="$1" skip + for skip in "${_compat_skip[@]}"; do [[ "$skip" == "$rel" ]] && return 0; done + return 1 + } # プロジェクト内ファイルへの hard link を指す絶対パス include を、closure 完了後に # まとめて照合するための保留キュー (staging 中は _seen が未確定のため、後続 sibling # を hard link 経由で見逃す。詳細は下の post-closure 検査を参照) _abs_ext=() + _index_seen=" " + _index_files=() + _index_one() { + local rel="$1" inc p + case "$_index_seen" in *" $rel "*) return 0 ;; esac + case "$rel" in + ../*|*/../*) + echo "[hawk-libs] desugar failed: include escapes project root via '..': $rel" >&2 + exit 1 + ;; + *" "*) + echo "[hawk-libs] desugar failed: include path contains spaces (unsupported): $rel" >&2 + exit 1 + ;; + .hawk-dist|.hawk-dist/*) + echo "[hawk-libs] desugar failed: '.hawk-dist' is reserved for the dist marker: $rel" >&2 + exit 1 + ;; + esac + _index_seen="$_index_seen$rel " + p="$src_root/$rel" + [[ -f "$p" ]] || return 0 + _index_files+=("$p") + while IFS= read -r inc; do + [[ -n "$inc" && "$inc" != /* ]] || continue + p="$inc" + while [[ "$p" == ./* ]]; do p="${p#./}"; done + while [[ "$p" == */./* ]]; do p="${p/\/\.\//\/}"; done + [[ "$p" == "$entry_rel" || "$src_root/$p" -ef "$src_root/$entry_rel" ]] && { + echo "[hawk-libs] desugar failed: include cycle back to entry: $rel includes $inc" >&2 + exit 1 + } + [[ -f "$src_root/$p" ]] && _index_one "$p" + done < <(gawk 'match($0, /^[[:space:]]*@include[[:space:]]+"([^"]+)"/, m) { print m[1] }' "$p") + return 0 + } + # rel: src_root からの相対パス。desugar して dist/rel に出力し、 # src_root 相対で実在する @include を dist 接頭に書き換えて再帰する。 _desugar_one() { - local rel="$1" in out out_parent out_rel probe part tmp inc incs prior + local rel="$1" in out out_parent tmp inc incs prior marker_src marker_rel marker_kind stable_rel + local compat_owned compat_other stable_owned stable_other case "$_seen" in *" $rel "*) return 0 ;; esac case "$rel" in ../*|*/../*) @@ -121,6 +400,10 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: '.hawk-dist' is reserved for the dist marker: $rel" >&2 exit 1 ;; + current|current/*|.hawk-generations|.hawk-generations/*) + echo "[hawk-libs] desugar failed: reserved output namespace: $rel" >&2 + exit 1 + ;; esac # 同一実体 (symlink/hardlink) を別名で include すると、mirror 後は # 別 inode になり gawk の重複 include 排除が効かず定義が二重実行される。 @@ -140,7 +423,60 @@ case "$subcmd" in set +f _seen="$_seen$rel " in="$src_root/$rel" - out="$dist/$rel" + stable_rel="${namespace:+$namespace/}$rel" + out="$stage_root/$stable_rel" + stable="$dist/$stable_rel" + compat="$dist/$rel" + compat_parent="$(dirname "$compat")" + while [[ ! -e "$compat_parent" && ! -L "$compat_parent" && "$compat_parent" != "." && "$compat_parent" != "/" ]]; do + compat_parent="$(dirname "$compat_parent")" + done + compat_parent_phys="$(cd "$compat_parent" && pwd -P)" + if [[ "$compat_parent_phys/" != "$dist_phys/"* ]]; then + echo "[hawk-libs] desugar failed: output directory escapes dist via symlink: $compat_parent_phys" >&2 + exit 1 + fi + if [[ -d "$stable" && ! -L "$stable" ]]; then + echo "[hawk-libs] desugar failed: output path is not replaceable: $stable" >&2 + exit 1 + fi + if [[ -e "$stable" && ! -L "$stable" ]] && ! _marker_owns_output "$src_root" "$stable_rel"; then + echo "[hawk-libs] desugar failed: output path is not replaceable: $stable" >&2 + exit 1 + fi + if [[ "$compat" != "$stable" && -d "$compat" && ! -L "$compat" ]]; then + echo "[hawk-libs] desugar failed: output path is not replaceable: $compat" >&2 + exit 1 + fi + if [[ "$compat" != "$stable" && -e "$compat" && ! -L "$compat" ]] && ! _marker_owns_output "$src_root" "$rel" compat; then + echo "[hawk-libs] desugar failed: output path is not replaceable: $compat" >&2 + exit 1 + fi + compat_owned=0; compat_other=0; stable_owned=0; stable_other=0 + if [[ -f "$dist/.hawk-dist" ]]; then + while IFS=$'\t' read -r marker_src marker_rel marker_kind; do + [[ "$marker_rel" == "$rel" ]] || continue + if [[ "$marker_kind" == compat ]]; then + if [[ "$marker_src" == "$src_root" ]]; then compat_owned=1; else compat_other=1; fi + elif [[ -z "$marker_kind" ]]; then + if [[ "$marker_src" == "$src_root" ]]; then stable_owned=1; else stable_other=1; fi + fi + done < "$dist/.hawk-dist" + fi + if [[ "$compat_owned" -eq 1 && "$compat_other" -eq 0 ]]; then + : + elif [[ "$compat_owned" -eq 1 || "$compat_other" -eq 1 || "$stable_other" -eq 1 ]]; then + _compat_skip+=("$rel") + elif [[ ( -e "$compat" || -L "$compat" ) && "$stable_owned" -eq 0 ]]; then + echo "[hawk-libs] desugar failed: refusing to overwrite existing compatibility output: $compat" >&2 + exit 1 + fi + if [[ -e "$stable" || -L "$stable" ]]; then + if [[ ! -f "$dist/.hawk-dist" ]] || ! grep -qxF -- "$src_root"$'\t'"$stable_rel" "$dist/.hawk-dist"; then + echo "[hawk-libs] desugar failed: refusing to overwrite existing namespaced output: $stable" >&2 + exit 1 + fi + fi # -ef は device/inode 比較。symlink 経由 (HAWK_DIST・source どちら側でも) # の source エイリアスを文字列比較より確実に検出する if [[ -e "$out" && "$in" -ef "$out" ]]; then @@ -165,44 +501,13 @@ case "$subcmd" in # 後勝ちの desugar が前の source の成果物を無警告で上書きしてしまう。 # dist パス自体の一意化は dist レイアウト規約の再設計 (#119) 側の課題であり、 # ここでは異なる source からの同名 rel 上書きを拒否するだけに留める - if [[ ! -f "$dist/.hawk-dist" ]] || ! grep -qxF -- "$src_root"$'\t'"$rel" "$dist/.hawk-dist"; then + if [[ ! -f "$dist/.hawk-dist" ]] || ! grep -qxF -- "$src_root"$'\t'"$stable_rel" "$dist/.hawk-dist"; then echo "[hawk-libs] desugar failed: refusing to overwrite existing file in non-dist directory: $out (remove it or the whole directory if it is a stale dist)" >&2 exit 1 fi fi - # mkdir -p は既存の symlink を辿るため、先に dist 配下の既存コンポーネントを - # 検査する。例えば dist/app -> 外部 の状態で app/sub を作ると、dist 外に - # sub/ が作られてから下の実パス検査に到達してしまう。 out_parent="$(dirname "$out")" - probe="$dist" - if [[ "$out_parent" == "$dist"/* ]]; then - out_rel="${out_parent#"$dist"/}" - while [[ -n "$out_rel" ]]; do - if [[ "$out_rel" == */* ]]; then - part="${out_rel%%/*}" - out_rel="${out_rel#*/}" - else - part="$out_rel" - out_rel= - fi - [[ -n "$part" ]] || continue - probe="$probe/$part" - if [[ -L "$probe" ]]; then - echo "[hawk-libs] desugar failed: output directory escapes dist via symlink: $probe" >&2 - exit 1 - fi - [[ -e "$probe" ]] || break - done - fi - mkdir -p "$out_parent" - # dist 配下のディレクトリが外部への symlink に差し替わっていると、mkdir -p はそれを - # 素通りし、後続の mv は symlink 先 (dist の外) に publish してしまう。 - # 出力先の親を実パスまで解決して dist の実パス配下であることを都度確認する - out_parent_phys="$(cd "$(dirname "$out")" && pwd -P)" - if [[ "$out_parent_phys/" != "$dist_phys/"* ]]; then - echo "[hawk-libs] desugar failed: output directory escapes dist via symlink: $out_parent_phys" >&2 - exit 1 - fi + _prepare_dist_parent "$out_parent" # 稼働中の hawk-serve が dist の成果物を再ロードしうるため、 # scratch (mktemp) 上で desugar と書き換えを済ませてから # mv でアトミックに配置する。scratch を mktemp にすることで @@ -213,7 +518,7 @@ case "$subcmd" in # dsl/desugar.awk 自身が @include "dsl/util.awk" 等の相対パスを持ち、 # gawk は '/' を含む @include をカレントディレクトリ相対で解決するため、 # HAWK_LIB (リポジトリルート) に cd してから実行する。 - if ! ( cd "$HAWK_LIB" && gawk -f dsl/desugar.awk "$in" ) > "$tmp"; then + if ! ( cd "$HAWK_LIB" && gawk -f "$_index_tmp" -f dsl/desugar.awk "$in" ) > "$tmp"; then rm -f "$tmp" echo "[hawk-libs] desugar failed: $in" >&2 exit 1 @@ -304,7 +609,7 @@ case "$subcmd" in # dist 接頭の実パスに書き換える tmp2="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" _scratch_arr+=("$tmp2") - if ! gawk -v dist="$dist" -v list="$incs" ' + if ! gawk -v dist="$runtime_ns" -v list="$incs" ' BEGIN { n = split(list, a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") L[a[i]] = 1 } { if (match($0, /^([[:space:]]*@include[[:space:]]+")([^"]+)(".*)$/, m) && (m[2] in L)) @@ -333,6 +638,19 @@ case "$subcmd" in } entry_rel="$(basename "$src")" + _index_one "$entry_rel" + # dsl/index.awk は HAWK_LIB へ cd して実行するため、相対 dist をそのまま渡すと + # work/dist ではなくリポジトリ直下へ出力しようとして失敗する。 + _index_tmp="$(mktemp "$dist_phys/.hawk-index.XXXXXX")" + _scratch_arr+=("$_index_tmp") + _index_list="" + for _index_file in "${_index_files[@]}"; do + _index_list="${_index_list}${_index_list:+$'\034'}${_index_file}" + done + if ! ( cd "$HAWK_LIB" && gawk -v V2_INDEX_LIST="$_index_list" -v V2_INDEX_OUT="$_index_tmp" -f dsl/index.awk ); then + echo "[hawk-libs] desugar failed: shared declaration index failed" >&2 + exit 1 + fi _desugar_one "$entry_rel" # 絶対パス include の hard link 検査は closure 全体の _seen が確定してから行う # (staging 中に照合すると未訪問の後続 sibling を見逃す)。publish の前に置き、 @@ -353,18 +671,46 @@ case "$subcmd" in chmod 644 "${_pub_tmp[$i]}" mv "${_pub_tmp[$i]}" "${_pub_out[$i]}" done - # marker は「今回 publish した rel」を既存の記録に追記して保持する。 + # marker は stable 所有者を2列、compat 所有者を3列目の compat で記録する。 + # compat は最初に作成した source に固定し、別 source の publish では追記しない。 # 空ファイルのままだと最初の成功後にディレクトリ全体を信頼してしまうため、 # rel を1行1件記録し、次回以降の上書き可否を rel 単位で判定できるようにする。 # 更新も稼働中の hawk-serve と競合しないよう mktemp + mv でアトミックに行う - _marker_tmp="$(mktemp "$dist/.hawk-dist.XXXXXX")" + _marker_tmp="$(mktemp "$stage_root/.hawk-dist.XXXXXX")" _scratch_arr+=("$_marker_tmp") { [[ -f "$dist/.hawk-dist" ]] && cat "$dist/.hawk-dist" - for _rel in "${_pub_rel[@]}"; do printf '%s\t%s\n' "$src_root" "$_rel"; done + for _rel in "${_pub_rel[@]}"; do printf '%s\t%s\n' "$src_root" "${namespace:+$namespace/}$_rel"; done + if [[ -n "$namespace" ]]; then + for _rel in "${_pub_rel[@]}"; do + _compat_is_skipped "$_rel" || printf '%s\t%s\tcompat\n' "$src_root" "$_rel" + done + fi } | sort -u > "$_marker_tmp" - mv "$_marker_tmp" "$dist/.hawk-dist" - echo "$dist/$entry_rel" + mv "$_marker_tmp" "$stage_root/.hawk-dist" + for ((i = 0; i < ${#_pub_rel[@]}; i++)); do + _rel="${_pub_rel[$i]}" + _prepare_dist_parent "$(dirname "$dist/${namespace:+$namespace/}$_rel")" + _prepare_dist_parent "$(dirname "$dist/$_rel")" + done + _current_tmp="$(mktemp "$dist/.current.XXXXXX")" + rm -f "$_current_tmp" + ln -s "${stage_root#"$dist/"}" "$_current_tmp" + # BSD mv の -h と GNU mv の -T は同じ「symlink 自体を置換」だが、片方しかない。 + # 実行時に -h の実移動を確認し、非対応なら -T へ切り替える(Why not: rm+mv は非 atomic)。 + _replace_symlink_atomically "$_current_tmp" "$dist/current" + _root_marker_tmp="$(mktemp "$dist/.hawk-dist.XXXXXX")" + cp "$stage_root/.hawk-dist" "$_root_marker_tmp" + mv -f "$_root_marker_tmp" "$dist/.hawk-dist" + for ((i = 0; i < ${#_pub_rel[@]}; i++)); do + _rel="${_pub_rel[$i]}" + _stable_rel="${namespace:+$namespace/}$_rel" + _stable="$dist/$_stable_rel" + _compat="$dist/$_rel" + _refresh_output_link "$_stable" "$runtime_ns/$_rel" "$src_root" "$_stable_rel" "" + [[ "$_compat" == "$_stable" ]] || _compat_is_skipped "$_rel" || _refresh_output_link "$_compat" "$runtime_ns/$_rel" "$src_root" "$_rel" compat + done + echo "$dist/${namespace:+$namespace/}$entry_rel" ;; *) echo "[hawk-libs] unknown subcommand: $subcmd" >&2 diff --git a/libexec/hawk-serve b/libexec/hawk-serve index 8b127512..1ccd85fc 100755 --- a/libexec/hawk-serve +++ b/libexec/hawk-serve @@ -8,7 +8,7 @@ fi set -e -HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" +export HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" HAWK_LIBEXEC="${HAWK_LIBEXEC:-$(cd "$(dirname "$0")" && pwd)}" LIBS="${HAWK_LIBEXEC}/hawk-libs" @@ -40,6 +40,8 @@ if [[ -f .env ]]; then set +a fi +HAWK_PLUGIN_DIRS="$("$LIBS" plugin-dirs)" +export HAWK_PLUGIN_DIRS mapfile -t _plugin_paths < <("$LIBS" plugins) _plugin_args=() for p in "${_plugin_paths[@]}"; do @@ -81,6 +83,7 @@ shutdown() { trap shutdown INT TERM APP_AWK="$("$LIBS" desugar "$APP")" +[[ "$APP_AWK" == /* ]] || APP_AWK="$(pwd)/$APP_AWK" # --workers N かつ supervisor 利用可能な場合は hawk-supervise に委譲する if [[ "$EFFECTIVE_WORKERS" -gt 1 && "$HAS_NET" -eq 1 ]]; then @@ -100,9 +103,13 @@ if [[ "$EFFECTIVE_WORKERS" -gt 1 && "$HAS_NET" -eq 1 ]]; then exit 0 fi +RUNTIME_AWK="$(mktemp "${TMPDIR:-/tmp}/hawk-runtime.XXXXXX")" +trap 'rm -f "$RUNTIME_AWK"' EXIT +"$LIBS" runtime-awk > "$RUNTIME_AWK" + for (( i=0; i&2 sleep 1 # shellcheck disable=SC2086 - LC_ALL=C gawk -b $LIBS_ARGS $LIBS_VARS -f hawk.awk "${_plugin_args[@]}" -f "$APP_AWK" & + LC_ALL=C gawk -b $LIBS_ARGS $LIBS_VARS -f "$RUNTIME_AWK" "${_plugin_args[@]}" -f "$APP_AWK" & new_pids+=($!) fi fi diff --git a/libexec/hawk-supervise b/libexec/hawk-supervise index 4b877f70..43183b1b 100755 --- a/libexec/hawk-supervise +++ b/libexec/hawk-supervise @@ -10,7 +10,7 @@ fi set -e HAWK_LIBEXEC="${HAWK_LIBEXEC:-$(cd "$(dirname "$0")" && pwd)}" -HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" +export HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" LIBS="${HAWK_LIBEXEC}/hawk-libs" APP_AWK="${1:?app artifact path required}" @@ -42,7 +42,19 @@ _setup_run_dir() { _setup_run_dir +HAWK_RUNTIME_AWK="$HAWK_RUN_DIR/runtime.awk" +_runtime_tmp="$(mktemp "$HAWK_RUN_DIR/.runtime.XXXXXX")" +if ! "$LIBS" runtime-awk > "$_runtime_tmp"; then + rm -f "$_runtime_tmp" + exit 1 +fi +chmod 0600 "$_runtime_tmp" +mv -f "$_runtime_tmp" "$HAWK_RUNTIME_AWK" +export HAWK_RUNTIME_AWK + eval "$("$LIBS" libs)" +HAWK_PLUGIN_DIRS="$("$LIBS" plugin-dirs)" +export HAWK_PLUGIN_DIRS mapfile -t _plugin_paths < <("$LIBS" plugins) _plugin_args=() for p in "${_plugin_paths[@]}"; do diff --git a/libexec/hawk-worker b/libexec/hawk-worker index d7596599..afb980b3 100755 --- a/libexec/hawk-worker +++ b/libexec/hawk-worker @@ -3,12 +3,13 @@ # libexec/hawk-worker -- supervised worker ラッパー HAWK_LIBEXEC="${HAWK_LIBEXEC:-$(cd "$(dirname "$0")" && pwd)}" -HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" +export HAWK_LIB="${HAWK_LIB:-$(cd "$(dirname "$0")/.." && pwd)}" LIBS="${HAWK_LIBEXEC}/hawk-libs" APP_AWK="${1:?app artifact path required}" shift PLUGIN_ARGS=("$@") +[[ "$APP_AWK" == /* ]] || APP_AWK="$(pwd)/$APP_AWK" if [[ -n "${HAWK_RUN_DIR:-}" && -n "${HAWK_PROC_ID:-}" ]]; then safe_pid="$BASHPID" @@ -21,5 +22,14 @@ fi eval "$("$LIBS" libs)" +if [[ "${HAWK_SUPERVISED:-}" == 1 ]]; then + if [[ ! -f "${HAWK_RUNTIME_AWK:-}" ]]; then + echo "[hawk-worker] supervised runtime bundle not found: ${HAWK_RUNTIME_AWK:-}" >&2 + exit 1 + fi + # shellcheck disable=SC2086 + exec gawk -b $LIBS_ARGS $LIBS_VARS -f "$HAWK_RUNTIME_AWK" "${PLUGIN_ARGS[@]}" -f "$APP_AWK" +fi + # shellcheck disable=SC2086 -exec gawk -b $LIBS_ARGS $LIBS_VARS -f "$HAWK_LIB/hawk.awk" "${PLUGIN_ARGS[@]}" -f "$APP_AWK" +exec gawk -b $LIBS_ARGS $LIBS_VARS -f <("$LIBS" runtime-awk) "${PLUGIN_ARGS[@]}" -f "$APP_AWK" diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 14f0943c..22434799 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -14,12 +14,12 @@ HAWK_MAX_BODY_SIZE=1024 \ SERVER=$! trap 'kill -TERM "$SERVER" 2>/dev/null || true; wait "$SERVER" 2>/dev/null || true' EXIT INT TERM -# 起動待ち (最大 3 秒) -for _ in 1 2 3 4 5 6 7 8 9 10; do - if curl -s -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null; then - break - fi - sleep 0.3 +# 起動待ち (最大 5 秒) +attempt=0 +until curl -s -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null; do + attempt=$((attempt + 1)) + [ "$attempt" -ge 50 ] && break + sleep 0.1 done PASS=0 diff --git a/tests/unit/cli/run.sh b/tests/unit/cli/run.sh index 1414f7b2..ebb6783a 100755 --- a/tests/unit/cli/run.sh +++ b/tests/unit/cli/run.sh @@ -81,6 +81,7 @@ rm -rf "$emit_multi" # check/emit は隔離した一時 dist を使い、cwd の永続 dist を作らない・更新しない HAWK_ABS="$(pwd)/bin/hawk" +HAWK_ROOT="$(pwd)" VALID_ABS="$(pwd)/$VALID" work=$(mktemp -d) ( cd "$work" && HAWK_NO_LIBS=1 "$HAWK_ABS" check --strict "$VALID_ABS" >/dev/null 2>&1 ) @@ -97,5 +98,100 @@ else fi rm -rf "$work" +# framework 外の cwd でも framework 本体・plugin・native lib を HAWK_LIB から解決する +work=$(mktemp -d) +work="$(cd "$work" && pwd -P)" +mkdir -p "$work/data" +printf 'name\noutside-cwd-ok\n' > "$work/data/items.tsv" +printf 'BEGIN { if (read_tsv("data/items.tsv", rows) == 1) print rows[1, "name"] }\n' > "$work/app.awk" +serve_out=$(cd "$work" && HAWK_NO_LIBS=1 HAWK_NO_SERVE=1 HAWK_WORKERS=1 "$HAWK_ABS" serve app.awk 2>/dev/null) +if [[ "$serve_out" == *"outside-cwd-ok"* ]]; then + printf " PASS: serve_outside_hawk_lib\n"; PASS=$((PASS+1)) +else + printf " FAIL: serve_outside_hawk_lib (output=%s)\n" "$serve_out"; FAIL=$((FAIL+1)) +fi +worker_out=$(cd "$work" && HAWK_LIB="$HAWK_ROOT" HAWK_NO_LIBS=1 HAWK_NO_SERVE=1 "$HAWK_ROOT/libexec/hawk-worker" "$work/app.awk" 2>/dev/null) +if [[ "$worker_out" == *"outside-cwd-ok"* ]]; then + printf " PASS: worker_outside_hawk_lib\n"; PASS=$((PASS+1)) +else + printf " FAIL: worker_outside_hawk_lib (output=%s)\n" "$worker_out"; FAIL=$((FAIL+1)) +fi +printf 'BEGIN { print ENVIRON["HAWK_LIB"] }\n' > "$work/env.awk" +worker_env=$(cd "$work" && unset HAWK_LIB && HAWK_NO_LIBS=1 HAWK_NO_SERVE=1 "$HAWK_ROOT/libexec/hawk-worker" "$work/env.awk" 2>/dev/null) +all_exported=1 +for entry in hawk-serve hawk-worker hawk-supervise hawk-check hawk-emit; do + grep -q '^export HAWK_LIB=' "$HAWK_ROOT/libexec/$entry" || all_exported=0 +done +if [[ "$worker_env" == "$HAWK_ROOT" && "$all_exported" -eq 1 ]]; then + printf " PASS: direct_entrypoints_export_hawk_lib\n"; PASS=$((PASS+1)) +else + printf " FAIL: direct_entrypoints_export_hawk_lib (worker_env=%s, all_exported=%s)\n" "$worker_env" "$all_exported"; FAIL=$((FAIL+1)) +fi + +framework="$work/framework" +mkdir -p "$framework/plugins/base" "$framework/plugins/demo" "$framework/libs/net/zig-out/lib" +printf '# manifest\n' > "$framework/plugins/base/manifest.awk" +printf '# plugin\n' > "$framework/plugins/base/base.awk" +printf '# manifest\n' > "$framework/plugins/demo/manifest.awk" +printf '# plugin\n' > "$framework/plugins/demo/demo.awk" +case "$(uname -s)" in Darwin) so_ext=dylib ;; *) so_ext=so ;; esac +touch "$framework/libs/net/zig-out/lib/libhawk_net.$so_ext" +plugins_out=$(cd "$work" && HAWK_LIB="$framework" "$HAWK_ROOT/libexec/hawk-libs" plugins) +if [[ "$plugins_out" == *"$framework/plugins/demo/manifest.awk"* && "$plugins_out" == *"$framework/plugins/demo/demo.awk"* ]]; then + printf " PASS: plugins_outside_hawk_lib\n"; PASS=$((PASS+1)) +else + printf " FAIL: plugins_outside_hawk_lib (output=%s)\n" "$plugins_out"; FAIL=$((FAIL+1)) +fi + +mkdir -p "$work/plugins/logger" +printf '# manifest\n' > "$work/plugins/logger/manifest.awk" +printf '# plugin\n' > "$work/plugins/logger/logger.awk" +mapfile -t plugin_paths < <(cd "$work" && HAWK_LIB="$framework" "$HAWK_ROOT/libexec/hawk-libs" plugins) +if [[ "${#plugin_paths[@]}" -eq 6 && "${plugin_paths[0]}" == "$framework/plugins/base/manifest.awk" && "${plugin_paths[2]}" == "$framework/plugins/demo/manifest.awk" && "${plugin_paths[4]}" == "$work/plugins/logger/manifest.awk" ]]; then + printf " PASS: plugins_dual_roots\n"; PASS=$((PASS+1)) +else + printf " FAIL: plugins_dual_roots (paths=%s)\n" "${plugin_paths[*]}"; FAIL=$((FAIL+1)) +fi + +mkdir -p "$work/plugins/demo" +printf '# app manifest\n' > "$work/plugins/demo/manifest.awk" +printf '# app plugin\n' > "$work/plugins/demo/demo.awk" +mapfile -t plugin_paths < <(cd "$work" && HAWK_LIB="$framework" "$HAWK_ROOT/libexec/hawk-libs" plugins) +if [[ "${#plugin_paths[@]}" -eq 6 && "${plugin_paths[2]}" == "$work/plugins/demo/manifest.awk" && "${plugin_paths[3]}" == "$work/plugins/demo/demo.awk" ]]; then + printf " PASS: plugins_app_shadow\n"; PASS=$((PASS+1)) +else + printf " FAIL: plugins_app_shadow (paths=%s)\n" "${plugin_paths[*]}"; FAIL=$((FAIL+1)) +fi + +touch "$work/plugins/demo/.disabled" +mapfile -t plugin_paths < <(cd "$work" && HAWK_LIB="$framework" "$HAWK_ROOT/libexec/hawk-libs" plugins) +if [[ "${#plugin_paths[@]}" -eq 4 && "${plugin_paths[0]}" == "$framework/plugins/base/manifest.awk" && "${plugin_paths[2]}" == "$work/plugins/logger/manifest.awk" ]]; then + printf " PASS: plugins_disabled_app_shadows\n"; PASS=$((PASS+1)) +else + printf " FAIL: plugins_disabled_app_shadows (paths=%s)\n" "${plugin_paths[*]}"; FAIL=$((FAIL+1)) +fi + +mapfile -t plugin_paths < <(cd "$work" && HAWK_LIB="$work" "$HAWK_ROOT/libexec/hawk-libs" plugins) +if [[ "${#plugin_paths[@]}" -eq 2 && "${plugin_paths[0]}" == "$work/plugins/logger/manifest.awk" && "${plugin_paths[1]}" == "$work/plugins/logger/logger.awk" ]]; then + printf " PASS: plugins_same_root_dedup\n"; PASS=$((PASS+1)) +else + printf " FAIL: plugins_same_root_dedup (paths=%s)\n" "${plugin_paths[*]}"; FAIL=$((FAIL+1)) +fi + +mapfile -t plugin_dirs < <(cd "$work" && HAWK_LIB="$framework" "$HAWK_ROOT/libexec/hawk-libs" plugin-dirs) +if [[ "${#plugin_dirs[@]}" -eq 2 && "${plugin_dirs[0]}" == "$framework/plugins/base" && "${plugin_dirs[1]}" == "$work/plugins/logger" ]]; then + printf " PASS: plugin_dirs_match_selection\n"; PASS=$((PASS+1)) +else + printf " FAIL: plugin_dirs_match_selection (dirs=%s)\n" "${plugin_dirs[*]}"; FAIL=$((FAIL+1)) +fi + +libs_out=$(cd "$work" && HAWK_LIB="$framework" "$HAWK_ROOT/libexec/hawk-libs" libs) +if [[ "$libs_out" == *"$framework/libs/net/zig-out/lib/libhawk_net.$so_ext"* && "$libs_out" == *"HAS_NET=1"* ]]; then + printf " PASS: libs_outside_hawk_lib\n"; PASS=$((PASS+1)) +else + printf " FAIL: libs_outside_hawk_lib (output=%s)\n" "$libs_out"; FAIL=$((FAIL+1)) +fi +rm -rf "$work" + printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" [[ $FAIL -eq 0 ]] diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 3c115cb6..17cc1e33 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -22,15 +22,47 @@ trap 'rm -rf "$TMP"' EXIT # --- 再帰 desugar + @include 書き換え + 実行 --- dist="$TMP/dist1" out=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk") -if [[ "$out" == "$dist/main.awk" ]]; then ok "entry_path_echoed"; else ng "entry_path_echoed" "$out"; fi +if [[ "$out" == "$dist/tests/unit/desugar/fixtures/proj/main.awk" ]]; then ok "entry_path_echoed"; else ng "entry_path_echoed" "$out"; fi if [[ -f "$dist/app/page.awk" ]]; then ok "included_file_mirrored"; else ng "included_file_mirrored"; fi if [[ -f "$dist/app/sub/util.awk" ]]; then ok "nested_include_mirrored"; else ng "nested_include_mirrored"; fi -if grep -qF "@include \"$dist/app/page.awk\"" "$dist/main.awk"; then ok "include_rewritten"; else ng "include_rewritten"; fi -if grep -qF "@include \"$dist/app/sub/util.awk\"" "$dist/app/page.awk"; then ok "nested_include_rewritten"; else ng "nested_include_rewritten"; fi +if grep -qF "@include \"$dist/current/" "$dist/main.awk" && grep -qF "app/page.awk\"" "$dist/main.awk"; then ok "include_rewritten"; else ng "include_rewritten"; fi +if grep -qF "@include \"$dist/current/" "$dist/app/page.awk" && grep -qF "app/sub/util.awk\"" "$dist/app/page.awk"; then ok "nested_include_rewritten"; else ng "nested_include_rewritten"; fi if ! grep -q "let " "$dist/app/page.awk"; then ok "included_file_desugared"; else ng "included_file_desugared"; fi run_out=$(gawk -f "$dist/main.awk" Str { return "shared" }\n' > "$shared/types.awk" +printf '@include "types.awk"\nfunction main() -> Response { let u: User = { name: get_name() }; return ctx.res.text(get_name()) }\n' > "$shared/main.awk" +if HAWK_DIST="$TMP/distshared" "$LIBS" desugar "$shared/main.awk" >/dev/null; then ok "shared_declarations_compile"; else ng "shared_declarations_compile"; fi + +shared_sig="$TMP/shared-sig" +mkdir -p "$shared_sig" +printf 'function normalize(x: Str) -> Str { return x }\n' > "$shared_sig/lib.awk" +printf '@include "lib.awk"\nfunction caller() { return normalize(123) }\nBEGIN { caller() }\n' > "$shared_sig/main.awk" +set +e +err=$(HAWK_DIST="$TMP/distsharedsig" "$LIBS" desugar "$shared_sig/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -ne 0 && "$err" == *"normalize argument 1 expects Str, got Int"* ]]; then ok "shared_signature_checks_raw_caller"; else ng "shared_signature_checks_raw_caller" "exit=$st: $err"; fi + +lex_state="$TMP/lex-state" +mkdir -p "$lex_state" +printf 'function dsl_body() {\n let x: Int = 1\n return x\n}\n' > "$lex_state/a.awk" +printf 'function raw(a, b) {\n return a + b\n}\n' > "$lex_state/b.awk" +printf '@include "a.awk"\n@include "b.awk"\nfunction caller() { return raw(1, 2) }\nBEGIN { print caller() }\n' > "$lex_state/main.awk" +lex_index="$TMP/lex-index.awk" +gawk -v V2_INDEX_LIST="$lex_state/a.awk"$'\034'"$lex_state/b.awk" -v V2_INDEX_OUT="$lex_index" -f dsl/index.awk +if HAWK_DIST="$TMP/distlexstate" "$LIBS" desugar "$lex_state/main.awk" >/dev/null && ! grep -qF 'V2_SHARED_SIG["raw"' "$lex_index"; then ok "index_lex_state_isolated_per_file"; else ng "index_lex_state_isolated_per_file" "raw function was indexed as DSL"; fi + +gen="$TMP/generation" +HAWK_DIST="$gen" "$LIBS" desugar "$FIX/solo.awk" >/dev/null +old_gen=$(readlink "$gen/current") +HAWK_DIST="$gen" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null +new_gen=$(readlink "$gen/current") +if [[ "$old_gen" != "$new_gen" && $(find -L "$gen/current" -type f -name main.awk | wc -l | tr -d ' ') -eq 1 && $(find "$gen/$old_gen" -type f -name solo.awk | wc -l | tr -d ' ') -eq 1 ]]; then ok "generation_switch_is_atomic"; else ng "generation_switch_is_atomic"; fi + # --- entry に戻る循環は exit 1 (gawk が実行時 fatal になるため desugar 時点で拒否) --- dist="$TMP/dist2" set +e @@ -71,7 +103,7 @@ fi # --- "./x.awk" 形式の include は正規化されて dist に書き換わる --- dist="$TMP/distds" if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/dotslash/main.awk" >/dev/null; then - if grep -qF "@include \"$dist/x.awk\"" "$dist/main.awk"; then ok "dotslash_include_normalized"; else ng "dotslash_include_normalized"; fi + if grep -qF "@include \"$dist/current/" "$dist/main.awk" && grep -qF "x.awk\"" "$dist/main.awk"; then ok "dotslash_include_normalized"; else ng "dotslash_include_normalized"; fi run_out=$(gawk -f "$dist/main.awk" "$reserved_ns/main.awk" +printf 'function reserved_x() { return 1 }\n' > "$reserved_ns/current/x.awk" +dist="$TMP/dist-reserved-namespace" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$reserved_ns/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"reserved output namespace: current/x.awk"* && ! -e "$dist/current" ]]; then ok "reserved_namespace_include_rejected"; else ng "reserved_namespace_include_rejected" "exit=$st: $err"; fi + +reserved_entry="$TMP/reserved-entry" +mkdir -p "$reserved_entry/.hawk-dist" +printf 'BEGIN { print "reserved-entry" }\n' > "$reserved_entry/.hawk-dist/main.awk" +dist="$TMP/dist-reserved-entry" +set +e +err=$(cd "$reserved_entry" && HAWK_DIST="$dist" "$LIBS_ABS" desugar .hawk-dist/main.awk 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *".hawk-dist/main.awk"* && ! -e "$dist/current" && ! -e "$dist/.hawk-dist" ]]; then ok "reserved_entry_namespace_rejected"; else ng "reserved_entry_namespace_rejected" "exit=$st: $err"; fi + # --- プロジェクト内を指す絶対パス include は exit 1、外部の絶対パスは素通り --- absinc="$TMP/absinc" mkdir -p "$absinc" @@ -357,53 +410,194 @@ set +e err=$(HAWK_DIST="$srctree/sub" "$LIBS" desugar "$srctree/main.awk" 2>&1 >/dev/null) st=$? set -e -if [[ "$st" -eq 1 && "$err" == *"refusing to overwrite"* ]]; then ok "dist_over_source_rejected"; else ng "dist_over_source_rejected" "exit=$st: $err"; fi +if [[ "$st" -eq 1 && "$err" == *"dist and source trees overlap"* ]]; then ok "dist_over_source_rejected"; else ng "dist_over_source_rejected" "exit=$st: $err"; fi after_hash=$(cksum < "$srctree/sub/main.awk") if [[ "$before_hash" == "$after_hash" ]]; then ok "dist_over_source_untouched"; else ng "dist_over_source_untouched" "source file was modified"; fi +# --- marker 所有の旧 flat regular file は generation symlink へ移行する --- +legacy_stable="$TMP/legacy-stable" +mkdir -p "$legacy_stable/dist" +printf 'BEGIN { print "new-stable" }\n' > "$legacy_stable/main.awk" +printf 'BEGIN { print "old-stable" }\n' > "$legacy_stable/dist/main.awk" +legacy_stable_src="$(cd "$legacy_stable" && pwd -P)" +printf '%s\tmain.awk\n' "$legacy_stable_src" > "$legacy_stable/dist/.hawk-dist" +set +e +err=$(cd "$legacy_stable" && HAWK_DIST=dist "$LIBS_ABS" desugar main.awk 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 0 && -L "$legacy_stable/dist/main.awk" && "$(gawk -f "$legacy_stable/dist/main.awk" "$legacy_compat_src/main.awk" +printf 'BEGIN { print "old-compat" }\n' > "$legacy_compat_dist/main.awk" +legacy_compat_src_phys="$(cd "$legacy_compat_src" && pwd -P)" +printf '%s\tmain.awk\tcompat\n' "$legacy_compat_src_phys" > "$legacy_compat_dist/.hawk-dist" +set +e +err=$(cd "$legacy_compat" && HAWK_DIST="$legacy_compat_dist" "$LIBS_ABS" desugar apps/a/main.awk 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 0 && -L "$legacy_compat_dist/main.awk" && -L "$legacy_compat_dist/apps/a/main.awk" && "$(gawk -f "$legacy_compat_dist/main.awk" "$legacy_flat_src/main.awk" +printf 'BEGIN { print "old-flat" }\n' > "$legacy_flat_dist/main.awk" +legacy_flat_src_phys="$(cd "$legacy_flat_src" && pwd -P)" +printf '%s\tmain.awk\n' "$legacy_flat_src_phys" > "$legacy_flat_dist/.hawk-dist" +set +e +err=$(cd "$legacy_flat" && HAWK_DIST=dist "$LIBS_ABS" desugar apps/a/main.awk 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 0 && -L "$legacy_flat_dist/main.awk" && -L "$legacy_flat_dist/apps/a/main.awk" && "$(gawk -f "$legacy_flat_dist/main.awk" "$legacy_dir/main.awk" +legacy_dir_src="$(cd "$legacy_dir" && pwd -P)" +printf '%s\tmain.awk\n' "$legacy_dir_src" > "$legacy_dir/dist/.hawk-dist" +set +e +err=$(cd "$legacy_dir" && HAWK_DIST=dist "$LIBS_ABS" desugar main.awk 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && -d "$legacy_dir/dist/main.awk" && ! -e "$legacy_dir/dist/current" && ! -L "$legacy_dir/dist/current" ]]; then ok "marker_owned_directory_rejected_early"; else ng "marker_owned_directory_rejected_early" "exit=$st, current=$([[ -e "$legacy_dir/dist/current" || -L "$legacy_dir/dist/current" ]] && echo yes || echo no): $err"; fi + # --- marker のある dist への再実行 (上書き) は成功する --- dist="$TMP/distrerun" HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null; then ok "rerun_over_marker_ok"; else ng "rerun_over_marker_ok" "exit != 0"; fi -# --- 異なる source の同名 basename entry (dist/main.awk が衝突) は marker に別 source -# として上書き拒否され、先に publish した成果物が生き残る --- -appsA="$TMP/appsA" -appsB="$TMP/appsB" -mkdir -p "$appsA" "$appsB" -printf 'BEGIN { print "from-a" }\n' > "$appsA/main.awk" -printf 'BEGIN { print "from-b" }\n' > "$appsB/main.awk" +# --- marker 未登録の compat symlink は上書きしない --- +compat_src="$TMP/unowned-compat-src" +dist="$TMP/unowned-compat-dist" +mkdir -p "$compat_src/apps/a" "$dist" +printf 'BEGIN { print "compat" }\n' > "$compat_src/apps/a/main.awk" +compat_target="$TMP/unowned-compat-target" +printf 'user file\n' > "$compat_target" +ln -s "$compat_target" "$dist/main.awk" +set +e +err=$(cd "$compat_src" && HAWK_DIST="$dist" "$LIBS_ABS" desugar apps/a/main.awk 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && -L "$dist/main.awk" && "$(readlink "$dist/main.awk")" == "$compat_target" && ! -e "$dist/current" ]]; then ok "unowned_compat_symlink_rejected"; else ng "unowned_compat_symlink_rejected" "exit=$st, target=$(readlink "$dist/main.awk" 2>/dev/null): $err"; fi + +# --- 改ざんされた stable symlink は現行 generation へ戻す --- +stale_src="$TMP/stale-stable-src" +mkdir -p "$stale_src" +cp "$FIX/solo.awk" "$stale_src/main.awk" +dist="$TMP/dist-stale-stable" +( cd "$stale_src" && HAWK_DIST="$dist" "$LIBS_ABS" desugar main.awk >/dev/null ) +stale_target="$TMP/stale-target.awk" +printf 'BEGIN { print "stale" }\n' > "$stale_target" +rm "$dist/main.awk" +ln -s "$stale_target" "$dist/main.awk" +( cd "$stale_src" && HAWK_DIST="$dist" "$LIBS_ABS" desugar main.awk >/dev/null ) +if [[ -L "$dist/main.awk" && "$(readlink "$dist/main.awk")" == "$dist/current/main.awk" ]]; then ok "stale_stable_symlink_refreshed"; else ng "stale_stable_symlink_refreshed" "target=$(readlink "$dist/main.awk" 2>/dev/null)"; fi + +stale_dir="$TMP/stale-directory-target" +mkdir -p "$stale_dir" +rm "$dist/main.awk" +ln -s "$stale_dir" "$dist/main.awk" +( cd "$stale_src" && HAWK_DIST="$dist" "$LIBS_ABS" desugar main.awk >/dev/null ) +if [[ -L "$dist/main.awk" && "$(readlink "$dist/main.awk")" == "$dist/current/main.awk" ]]; then ok "stale_directory_symlink_refreshed"; else ng "stale_directory_symlink_refreshed" "target=$(readlink "$dist/main.awk" 2>/dev/null)"; fi + +# --- record と alias が include 間で同名なら index 時点で拒否する --- +type_collision="$TMP/type-collision" +mkdir -p "$type_collision" +printf 'type User = { name: Str }\n' > "$type_collision/record.awk" +printf 'type User = Str\n' > "$type_collision/alias.awk" +printf '@include "record.awk"\n@include "alias.awk"\nBEGIN { print "collision" }\n' > "$type_collision/main.awk" +printf '@include "alias.awk"\n@include "record.awk"\nBEGIN { print "collision" }\n' > "$type_collision/reverse.awk" +dist="$TMP/dist-type-collision" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$type_collision/main.awk" 2>&1 >/dev/null) +st=$? +err_reverse=$(HAWK_DIST="$TMP/dist-type-collision-reverse" "$LIBS" desugar "$type_collision/reverse.awk" 2>&1 >/dev/null) +st_reverse=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"type name is both record and alias: User"* && "$st_reverse" -eq 1 && "$err_reverse" == *"type name is both record and alias: User"* ]]; then ok "alias_record_name_collision_rejected"; else ng "alias_record_name_collision_rejected" "forward=$st: $err / reverse=$st_reverse: $err_reverse"; fi + +# --- 異なる source の同名 basename entry は namespace で分離される --- +multi_entry="$TMP/multi-entry" +mkdir -p "$multi_entry/apps/a" "$multi_entry/apps/b" +printf 'BEGIN { print "from-a" }\n' > "$multi_entry/apps/a/main.awk" +printf 'BEGIN { print "from-b" }\n' > "$multi_entry/apps/b/main.awk" dist="$TMP/distcs" -HAWK_DIST="$dist" "$LIBS" desugar "$appsA/main.awk" >/dev/null -before_hash=$(cksum < "$dist/main.awk") +( cd "$multi_entry" && HAWK_DIST="$dist" "$LIBS_ABS" desugar apps/a/main.awk >/dev/null ) +first_a="$dist/$(readlink "$dist/current")/apps/a/main.awk" +compat_a="$(readlink "$dist/main.awk")" +( cd "$multi_entry" && HAWK_DIST="$dist" "$LIBS_ABS" desugar apps/b/main.awk >/dev/null ) +compat_after_b="$(readlink "$dist/main.awk")" +( cd "$multi_entry" && HAWK_DIST="$dist" "$LIBS_ABS" desugar apps/b/main.awk >/dev/null ) +if [[ "$compat_after_b" == "$compat_a" && "$(readlink "$dist/main.awk")" == "$compat_a" ]]; then ok "cross_source_compat_owner_persists_on_rerun"; else ng "cross_source_compat_owner_persists_on_rerun"; fi +entries=$(find -L "$dist/current" -type f -name main.awk | wc -l | tr -d ' ') +if [[ "$entries" -eq 2 && "$(readlink "$dist/main.awk")" == "$compat_a" ]] && grep -q "from-a" "$dist/apps/a/main.awk" && grep -q "from-b" "$dist/apps/b/main.awk"; then + ok "cross_source_same_basename_isolated" +else + ng "cross_source_same_basename_isolated" "entries=$entries" +fi +run_a=$(gawk -f "$dist/apps/a/main.awk" /dev/null ) +if ( cd "$multi_entry" && HAWK_DIST="$dist_namespace_owner" "$LIBS_ABS" desugar apps/a/main.awk >/dev/null ); then + ok "namespaced_same_cwd_rerun" +else + ng "namespaced_same_cwd_rerun" +fi +mkdir -p "$dist_namespace_owner/a" +unrelated_namespace_target="$TMP/unrelated-namespace-target.awk" +printf 'BEGIN { print "unrelated" }\n' > "$unrelated_namespace_target" +ln -s "$unrelated_namespace_target" "$dist_namespace_owner/a/main.awk" +unrelated_namespace_link="$(readlink "$dist_namespace_owner/a/main.awk")" set +e -err=$(HAWK_DIST="$dist" "$LIBS" desugar "$appsB/main.awk" 2>&1 >/dev/null) +err=$(cd "$multi_entry/apps" && HAWK_DIST="$dist_namespace_owner" "$LIBS_ABS" desugar a/main.awk 2>&1 >/dev/null) st=$? set -e -after_hash=$(cksum < "$dist/main.awk") -if [[ "$st" -eq 1 && "$err" == *"refusing to overwrite"* && "$before_hash" == "$after_hash" ]]; then - ok "cross_source_same_basename_rejected" +if [[ "$st" -eq 1 && "$err" == *"refusing to overwrite existing namespaced output"* && -L "$dist_namespace_owner/a/main.awk" && "$(readlink "$dist_namespace_owner/a/main.awk")" == "$unrelated_namespace_link" ]]; then + ok "cross_cwd_namespace_marker_isolated" else - ng "cross_source_same_basename_rejected" "exit=$st: $err" + ng "cross_cwd_namespace_marker_isolated" "exit=$st: $err" fi +owner_a="$(cd "$multi_entry/apps/a" && pwd -P)" +owner_b="$(cd "$multi_entry/apps/b" && pwd -P)" +dist_missing_other="$TMP/dist-compat-missing-other" +( cd "$multi_entry" && HAWK_DIST="$dist_missing_other" "$LIBS_ABS" desugar apps/a/main.awk >/dev/null ) +rm "$dist_missing_other/main.awk" +set +e +( cd "$multi_entry" && HAWK_DIST="$dist_missing_other" "$LIBS_ABS" desugar apps/b/main.awk >/dev/null ) +st=$? +set -e +if [[ "$st" -eq 0 && ! -e "$dist_missing_other/main.awk" && ! -L "$dist_missing_other/main.awk" ]] && ! grep -qxF -- "$owner_b"$'\t'main.awk$'\t'compat "$dist_missing_other/.hawk-dist"; then ok "missing_compat_other_source_skips"; else ng "missing_compat_other_source_skips" "exit=$st, compat=$([[ -e "$dist_missing_other/main.awk" || -L "$dist_missing_other/main.awk" ]] && echo yes || echo no)"; fi + +dist_missing_self="$TMP/dist-compat-missing-self" +( cd "$multi_entry" && HAWK_DIST="$dist_missing_self" "$LIBS_ABS" desugar apps/a/main.awk >/dev/null ) +compat_self="$(readlink "$dist_missing_self/main.awk")" +rm "$dist_missing_self/main.awk" +( cd "$multi_entry" && HAWK_DIST="$dist_missing_self" "$LIBS_ABS" desugar apps/a/main.awk >/dev/null ) +if [[ -L "$dist_missing_self/main.awk" && "$(readlink "$dist_missing_self/main.awk")" == "$compat_self" ]] && grep -qxF -- "$owner_a"$'\t'main.awk$'\t'compat "$dist_missing_self/.hawk-dist"; then ok "missing_compat_owner_repairs"; else ng "missing_compat_owner_repairs"; fi + # --- marker は publish 済み rel のみ信頼し、同じ dist 配下の無関係な既存ファイルは # 引き続き上書き保護される (marker が空ファイルだと最初の成功後に # ディレクトリ全体を信頼してしまう問題の再現) --- srcsub2="$TMP/srcsub2" mkdir -p "$srcsub2/sub" cp "$FIX/solo.awk" "$srcsub2/main.awk" -HAWK_DIST="$srcsub2/sub" "$LIBS" desugar "$srcsub2/main.awk" >/dev/null -printf 'BEGIN { print "other-source" }\n' > "$srcsub2/sub/other.awk" -before_hash=$(cksum < "$srcsub2/sub/other.awk") -cp "$FIX/solo.awk" "$srcsub2/other.awk" -set +e -err=$(HAWK_DIST="$srcsub2/sub" "$LIBS" desugar "$srcsub2/other.awk" 2>&1 >/dev/null) -st=$? -set -e -if [[ "$st" -eq 1 && "$err" == *"refusing to overwrite"* ]]; then ok "marker_does_not_bless_unrelated_files"; else ng "marker_does_not_bless_unrelated_files" "exit=$st: $err"; fi -after_hash=$(cksum < "$srcsub2/sub/other.awk") -if [[ "$before_hash" == "$after_hash" ]]; then ok "marker_does_not_bless_unrelated_files_untouched"; else ng "marker_does_not_bless_unrelated_files_untouched" "source file was modified"; fi +before_hash=$(cksum < "$srcsub2/main.awk") +if HAWK_DIST="$srcsub2/sub" "$LIBS" desugar "$srcsub2/main.awk" >/dev/null; then ok "source_tree_dist_published"; else ng "source_tree_dist_published"; fi +after_hash=$(cksum < "$srcsub2/main.awk") +if [[ "$before_hash" == "$after_hash" && $(find -L "$srcsub2/sub/current" -type f -name main.awk | wc -l | tr -d ' ') -eq 1 ]]; then ok "source_tree_dist_source_untouched"; else ng "source_tree_dist_source_untouched"; fi # --- include 名に glob 文字が含まれても、_seen の未クォート展開で # カレントディレクトリの同名ファイルと誤って alias 判定されない --- @@ -433,6 +627,10 @@ cp "$FIX/solo.awk" "$work/main.awk" out=$(cd "$work" && "$LIBS_ABS" desugar main.awk) if [[ "$out" == "dist/main.awk" && -f "$work/dist/main.awk" ]]; then ok "default_dist_dir"; else ng "default_dist_dir" "$out"; fi +# --- HAWK_DIST の末尾 / は親ディレクトリ検査前に正規化する --- +dist="$TMP/dist-trailing/" +if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" >/dev/null && [[ -f "${dist%/}/solo.awk" ]]; then ok "hawk_dist_trailing_slash_normalized"; else ng "hawk_dist_trailing_slash_normalized"; fi + # --- 入力ファイル不在は従来どおり exit 1 --- set +e "$LIBS" desugar "$TMP/nope.awk" >/dev/null 2>&1 @@ -544,5 +742,22 @@ else ng "symlinked_parent_no_external_mkdir" "exit=$st, outside_sub=$([[ -e "$symlink_parent_outside/sub" ]] && echo yes || echo no): $err" fi +# --- stable namespace の親 symlink は作成前に拒否し、外部へ出力しない --- +stable_parent_src="$TMP/stable-parent-src" +stable_parent_dist="$TMP/stable-parent-dist" +stable_parent_outside="$TMP/stable-parent-outside" +mkdir -p "$stable_parent_src/app" "$stable_parent_dist" "$stable_parent_outside" +printf 'BEGIN { print "stable-parent" }\n' > "$stable_parent_src/app/main.awk" +ln -s "$stable_parent_outside" "$stable_parent_dist/app" +set +e +err=$(cd "$stable_parent_src" && HAWK_DIST="$stable_parent_dist" "$LIBS_ABS" desugar app/main.awk 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && ! -e "$stable_parent_outside/main.awk" && ! -e "$stable_parent_dist/current" ]]; then + ok "stable_symlink_parent_escape_rejected" +else + ng "stable_symlink_parent_escape_rejected" "exit=$st, external=$([[ -e "$stable_parent_outside/main.awk" ]] && echo yes || echo no): $err" +fi + printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" [[ $FAIL -eq 0 ]] diff --git a/tests/unit/run.awk b/tests/unit/run.awk index f5c5849a..8a12bd83 100644 --- a/tests/unit/run.awk +++ b/tests/unit/run.awk @@ -198,6 +198,7 @@ BEGIN { test_plugin_call_hooks_normal() test_plugin_call_hooks_abort() test_plugin_missing_config() + test_plugin_discover_hawk_lib_root() test_libs_binary_length() test_libs_binary_read_text() diff --git a/tests/unit/test_plugin.awk b/tests/unit/test_plugin.awk index 716b0e2d..c3335da1 100644 --- a/tests/unit/test_plugin.awk +++ b/tests/unit/test_plugin.awk @@ -92,7 +92,7 @@ function _test_plugin_discover_unknown_function_guard( cmd, line, output, rc) cmd = "tmp=$(mktemp -d); " \ "mkdir -p \"$tmp/plugins/missing\"; " \ "err=\"$tmp/err\"; " \ - "(cd \"$tmp\" && gawk -f \"" ENVIRON["PWD"] "/core/plugin.awk\" -e 'BEGIN { plugin_discover(); exit 0 }') 2>\"$err\"; " \ + "(cd \"$tmp\" && gawk -f \"" ENVIRON["PWD"] "/core/util.awk\" -f \"" ENVIRON["PWD"] "/core/plugin.awk\" -e 'BEGIN { plugin_discover(); exit 0 }') 2>\"$err\"; " \ "rc=$?; " \ "printf 'RC:%s\\n' \"$rc\"; " \ "cat \"$err\"; " \ @@ -107,3 +107,33 @@ function _test_plugin_discover_unknown_function_guard( cmd, line, output, rc) assert_true(index(output, "plugin: function not found: plugin_missing_manifest") > 0, "plugin: unknown manifest function logs stderr") } + +function test_plugin_discover_hawk_lib_root( cmd, line, output) { + cmd = "tmp=$(mktemp -d); " \ + "framework=\"$tmp/framework\"; app=\"$tmp/app\"; " \ + "mkdir -p \"$framework/plugins/demo\" \"$app/plugins/local\"; " \ + "printf 'function plugin_demo_manifest(meta) { meta[\"version\"] = \"1.2.3\" }\\n' > \"$framework/plugins/demo/manifest.awk\"; " \ + "printf 'function plugin_local_manifest(meta) { meta[\"version\"] = \"4.5.6\" }\\n' > \"$app/plugins/local/manifest.awk\"; " \ + "dirs=$(printf '%s\\n%s' \"$framework/plugins/demo\" \"$app/plugins/local\"); " \ + "(cd \"$app\" && HAWK_LIB=\"$framework\" HAWK_PLUGIN_DIRS=\"$dirs\" gawk " \ + "-f \"" ENVIRON["PWD"] "/core/util.awk\" " \ + "-f \"" ENVIRON["PWD"] "/core/plugin.awk\" " \ + "-f \"$framework/plugins/demo/manifest.awk\" " \ + "-f \"$app/plugins/local/manifest.awk\" " \ + "-e 'BEGIN { plugin_discover(); print \"ENV_FW:\" PLUGINS[\"demo\", \"version\"]; print \"ENV_APP:\" PLUGINS[\"local\", \"version\"]; exit 0 }') 2>&1; " \ + "(cd \"$app\" && HAWK_LIB=\"$framework\" gawk " \ + "-f \"" ENVIRON["PWD"] "/core/util.awk\" " \ + "-f \"" ENVIRON["PWD"] "/core/plugin.awk\" " \ + "-f \"$framework/plugins/demo/manifest.awk\" " \ + "-f \"$app/plugins/local/manifest.awk\" " \ + "-e 'BEGIN { plugin_discover(); print \"FALLBACK_FW:\" PLUGINS[\"demo\", \"version\"]; print \"FALLBACK_APP:\" ((\"local\", \"version\") in PLUGINS); exit 0 }') 2>&1; " \ + "rm -rf \"$tmp\"" + output = "" + while ((cmd | getline line) > 0) output = output line "\n" + close(cmd) + + assert_true(index(output, "ENV_FW:1.2.3\n") > 0, "plugin: discover env includes framework root") + assert_true(index(output, "ENV_APP:4.5.6\n") > 0, "plugin: discover env includes app root") + assert_true(index(output, "FALLBACK_FW:1.2.3\n") > 0, "plugin: discover fallback uses HAWK_LIB root") + assert_true(index(output, "FALLBACK_APP:0\n") > 0, "plugin: discover fallback ignores cwd plugins") +}